Initial local backup snapshot

This commit is contained in:
Selecta Keke 2026-06-24 00:02:55 -03:00
commit acd9e14ba5
367 changed files with 118038 additions and 0 deletions

962
server/_core/auth.ts Normal file
View file

@ -0,0 +1,962 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { ForbiddenError } from "@shared/_core/errors";
import { SignJWT, jwtVerify } from "jose";
import * as crypto from "node:crypto";
import { promisify } from "node:util";
import { parse as parseCookieHeader } from "cookie";
import type { Request, Response } from "express";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
import { getSessionCookieOptions } from "./cookies";
import { canSendOperationalEmails, sendOperationalEmail } from "../mailer";
const scryptAsync = promisify(crypto.scrypt);
const argon2Async =
typeof (crypto as any).argon2 === "function"
? promisify((crypto as any).argon2)
: null;
const KEY_LENGTH = 64;
const LOGIN_FAILURE_LIMIT = 5;
const LOGIN_LOCK_DURATION_MS = 15 * 60 * 1000;
const MFA_CODE_TTL_MS = 10 * 60 * 1000;
const MFA_CODE_ATTEMPT_LIMIT = 5;
const RETENTION_REPORT_SETTING_KEY = "retention.lastReport";
const SYSTEM_ACTOR_USER_ID = 0;
const INTERNAL_MFA_POLICY = {
super_admin: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: "authenticator_app",
emailFallbackAllowed: false,
phaseLabel: "Phase 2 cible",
},
admin: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: "authenticator_app",
emailFallbackAllowed: false,
phaseLabel: "Phase 2 active",
},
directrice: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: "authenticator_app",
emailFallbackAllowed: false,
phaseLabel: "Phase 2 active",
},
accueil: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: null,
emailFallbackAllowed: true,
phaseLabel: "Phase 1 active",
},
service_terrain: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: null,
emailFallbackAllowed: true,
phaseLabel: "Phase 1 active",
},
logistique_controle: {
required: true,
preferredMethod: "authenticator_app",
enforcedMethod: null,
emailFallbackAllowed: true,
phaseLabel: "Phase 1 active",
},
} as const;
class AccountLockedError extends Error {
constructor(public lockedUntil: Date) {
super(`Compte temporairement verrouillé jusqu'au ${lockedUntil.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}.`);
}
}
class MfaChallengeRequiredError extends Error {
constructor(
public challengeType: "email" | "totp",
public challengeToken: string,
public expiresAt: Date,
public maskedEmail?: string,
) {
super("Code de verification requis");
}
}
type JwtPayload = {
userId: number;
openId: string;
};
type BootstrapUserConfig = {
email: string;
password: string;
name?: string;
role?: User["role"];
};
function createBootstrapUser(config: BootstrapUserConfig, index: number): User {
const email = normalizeEmail(config.email);
const now = new Date();
return {
id: index + 1,
openId: email,
name: config.name ?? email.split("@")[0] ?? "Utilisateur",
email,
passwordHash: null,
loginMethod: "bootstrap_jwt",
role: config.role ?? "admin",
canManageLogistics: config.role === "super_admin",
delegatedSalleSignerUserId: null,
privacyConsentVersion: null,
privacyConsentAcceptedAt: null,
privacyConsentContext: null,
failedLoginAttempts: 0,
lockedUntil: null,
mfaEnabled: false,
mfaMethod: null,
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
mfaTotpSecretEncrypted: null,
mfaTotpPendingSecretEncrypted: null,
deletionRequestedAt: null,
purgeScheduledAt: null,
purgedAt: null,
legalHold: false,
legalHoldReason: null,
isActive: true,
createdAt: now,
updatedAt: now,
lastSignedIn: now,
};
}
function getBootstrapUserConfigs(): BootstrapUserConfig[] {
const users: BootstrapUserConfig[] = [];
if (ENV.bootstrapUsers) {
try {
const parsed = JSON.parse(ENV.bootstrapUsers) as BootstrapUserConfig[];
if (Array.isArray(parsed)) {
users.push(...parsed);
}
} catch (error) {
console.error("[Auth] BOOTSTRAP_USERS is not valid JSON", error);
}
}
if (ENV.bootstrapAdminEmail && ENV.bootstrapAdminPassword) {
users.push({
email: ENV.bootstrapAdminEmail,
password: ENV.bootstrapAdminPassword,
name: ENV.bootstrapAdminName,
role: "super_admin",
});
}
return users;
}
function getBootstrapUsers(): User[] {
return getBootstrapUserConfigs().map((config, index) =>
createBootstrapUser(config, index)
);
}
function findBootstrapUserByCredentials(input: { email: string; password: string }) {
const email = normalizeEmail(input.email);
const configs = getBootstrapUserConfigs();
const index = configs.findIndex(
config => normalizeEmail(config.email) === email && config.password === input.password
);
if (index === -1) return null;
return createBootstrapUser(configs[index], index);
}
function findBootstrapUserByOpenId(openId: string) {
return getBootstrapUsers().find(user => user.openId === openId) ?? null;
}
function getJwtSecret() {
const secret = ENV.cookieSecret;
if (!secret && ENV.isProduction) {
throw new Error("JWT_SECRET is required in production");
}
return new TextEncoder().encode(secret || "local-dev-secret-change-me");
}
function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
async function ensureConfiguredOwnerPrivileges(user: User): Promise<User> {
if (!user.email || !ENV.adminEmail) return user;
const isConfiguredOwner = normalizeEmail(user.email) === normalizeEmail(ENV.adminEmail);
if (!isConfiguredOwner) return user;
if (user.role === "super_admin" && user.canManageLogistics) {
return user;
}
await db.updateUser(user.id, {
role: "super_admin",
canManageLogistics: true,
});
return {
...user,
role: "super_admin",
canManageLogistics: true,
};
}
function parseCookies(cookieHeader: string | undefined) {
if (!cookieHeader) return new Map<string, string>();
return new Map(Object.entries(parseCookieHeader(cookieHeader)));
}
function hashOtpCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex");
}
function buildSecurityCipherKey() {
return crypto.createHash("sha256")
.update(ENV.cookieSecret || "local-dev-auth-security")
.digest();
}
function encryptSecuritySecret(value: string) {
const iv = crypto.randomBytes(12);
const key = buildSecurityCipherKey();
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString("base64")}.${tag.toString("base64")}.${encrypted.toString("base64")}`;
}
function decryptSecuritySecret(value?: string | null) {
if (!value) return "";
const [ivBase64, tagBase64, encryptedBase64] = value.split(".");
if (!ivBase64 || !tagBase64 || !encryptedBase64) return "";
try {
const key = buildSecurityCipherKey();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(ivBase64, "base64"));
decipher.setAuthTag(Buffer.from(tagBase64, "base64"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedBase64, "base64")),
decipher.final(),
]);
return decrypted.toString("utf8");
} catch {
return "";
}
}
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function encodeBase32(buffer: Buffer) {
let bits = 0;
let value = 0;
let output = "";
for (let index = 0; index < buffer.length; index += 1) {
const byte = buffer[index]!;
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
function decodeBase32(input: string) {
const normalized = input.toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, "");
let bits = 0;
let value = 0;
const output: number[] = [];
for (const char of normalized) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) continue;
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(output);
}
function generateTotpSecret() {
return encodeBase32(crypto.randomBytes(20));
}
function buildOtpAuthUrl(email: string, secret: string) {
const label = encodeURIComponent(`Portail Associations:${email}`);
const issuer = encodeURIComponent("Portail Associations");
return `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`;
}
function generateTotpCode(secret: string, timestamp: number) {
const key = decodeBase32(secret);
const counter = Math.floor(timestamp / 30_000);
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const hmac = crypto.createHmac("sha1", key).update(counterBuffer).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const binary = ((hmac[offset] & 0x7f) << 24)
| ((hmac[offset + 1] & 0xff) << 16)
| ((hmac[offset + 2] & 0xff) << 8)
| (hmac[offset + 3] & 0xff);
return String(binary % 1_000_000).padStart(6, "0");
}
function verifyTotpCode(secret: string, code: string) {
const trimmedCode = code.trim();
const now = Date.now();
for (const drift of [-30_000, 0, 30_000]) {
if (generateTotpCode(secret, now + drift) === trimmedCode) {
return true;
}
}
return false;
}
function maskEmail(email: string) {
const [localPart, domain = ""] = email.split("@");
if (!localPart) return email;
const visibleStart = localPart.slice(0, 2);
const visibleEnd = localPart.length > 4 ? localPart.slice(-1) : "";
const maskedCore = "*".repeat(Math.max(localPart.length - visibleStart.length - visibleEnd.length, 1));
return `${visibleStart}${maskedCore}${visibleEnd}@${domain}`;
}
function generateOtpCode() {
return String(Math.floor(100000 + Math.random() * 900000));
}
function getPasswordHashAlgorithm(passwordHash: string | null | undefined) {
if (!passwordHash) return null;
const [algorithm] = passwordHash.split(":");
return algorithm || null;
}
export function getInternalMfaPolicy(role: User["role"]) {
return INTERNAL_MFA_POLICY[role as keyof typeof INTERNAL_MFA_POLICY] ?? null;
}
export function isInternalRole(role: User["role"]) {
return Boolean(getInternalMfaPolicy(role));
}
function requiresMfaByRole(user: User) {
return Boolean(getInternalMfaPolicy(user.role)?.required);
}
function requiresTotpByRole(user: User) {
return getInternalMfaPolicy(user.role)?.enforcedMethod === "authenticator_app";
}
function allowsEmailMfaByRole(user: User) {
return getInternalMfaPolicy(user.role)?.emailFallbackAllowed !== false;
}
function getEffectiveMfaMethod(user: User): "email" | "totp" | null {
const hasTotp = Boolean(user.mfaMethod === "authenticator_app" && user.mfaTotpSecretEncrypted);
if (hasTotp) return "totp";
if ((user.mfaEnabled || requiresMfaByRole(user)) && user.email && allowsEmailMfaByRole(user)) return "email";
return null;
}
async function hashPassword(password: string) {
if (!argon2Async) {
const salt = crypto.randomBytes(16).toString("hex");
const derivedKey = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;
return `scrypt:${salt}:${derivedKey.toString("hex")}`;
}
const nonce = crypto.randomBytes(16);
const derivedKey = (await argon2Async("argon2id", {
message: Buffer.from(password),
nonce,
parallelism: 1,
tagLength: 32,
memory: 65536,
passes: 3,
})) as Buffer;
return `argon2id:${nonce.toString("base64url")}:${derivedKey.toString("base64url")}`;
}
export async function hashLocalPassword(password: string) {
return hashPassword(password);
}
async function verifyPassword(password: string, passwordHash: string | null | undefined) {
if (!passwordHash) return false;
const [algorithm, salt, key] = passwordHash.split(":");
if (algorithm === "argon2id" && salt && key && argon2Async) {
const nonce = Buffer.from(salt, "base64url");
const expected = Buffer.from(key, "base64url");
const derivedKey = (await argon2Async("argon2id", {
message: Buffer.from(password),
nonce,
parallelism: 1,
tagLength: expected.length,
memory: 65536,
passes: 3,
})) as Buffer;
return derivedKey.length === expected.length && crypto.timingSafeEqual(derivedKey, expected);
}
if (algorithm !== "scrypt" || !salt || !key) return false;
const storedKey = Buffer.from(key, "hex");
const derivedKey = (await scryptAsync(password, salt, storedKey.length)) as Buffer;
if (storedKey.length !== derivedKey.length) return false;
return crypto.timingSafeEqual(storedKey, derivedKey);
}
async function resetLoginSecurityState(userId: number) {
await db.updateUser(userId, {
failedLoginAttempts: 0,
lockedUntil: null,
});
}
async function registerLoginFailure(user: User) {
const nextAttempts = (user.failedLoginAttempts || 0) + 1;
const lockNow = nextAttempts >= LOGIN_FAILURE_LIMIT;
await db.updateUser(user.id, {
failedLoginAttempts: lockNow ? 0 : nextAttempts,
lockedUntil: lockNow ? new Date(Date.now() + LOGIN_LOCK_DURATION_MS) : null,
});
}
function assertUserNotLocked(user: User) {
if (user.lockedUntil && new Date(user.lockedUntil) > new Date()) {
throw new AccountLockedError(new Date(user.lockedUntil));
}
}
async function sendLoginMfaChallenge(user: User) {
if (requiresTotpByRole(user) && !user.mfaTotpSecretEncrypted) {
throw new Error("Ce rôle interne doit utiliser l'application Authenticator. Faites initialiser le compte avant la prochaine connexion.");
}
const effectiveMethod = getEffectiveMfaMethod(user);
if (effectiveMethod === "totp") {
const challengeToken = crypto.randomBytes(24).toString("base64url");
const expiresAt = new Date(Date.now() + MFA_CODE_TTL_MS);
await db.updateUser(user.id, {
mfaChallengeToken: challengeToken,
mfaCodeHash: null,
mfaCodeExpiresAt: expiresAt,
mfaCodeAttempts: 0,
});
return new MfaChallengeRequiredError("totp", challengeToken, expiresAt);
}
if (!user.email) {
throw new Error("Ce compte ne dispose pas d'une adresse email exploitable pour le MFA.");
}
const mailReady = await canSendOperationalEmails();
if (!mailReady) {
throw new Error("Le MFA par email ne peut pas etre active tant que le SMTP n'est pas configure.");
}
const code = generateOtpCode();
const expiresAt = new Date(Date.now() + MFA_CODE_TTL_MS);
const challengeToken = crypto.randomBytes(24).toString("base64url");
await db.updateUser(user.id, {
mfaChallengeToken: challengeToken,
mfaCodeHash: hashOtpCode(code),
mfaCodeExpiresAt: expiresAt,
mfaCodeAttempts: 0,
});
await sendOperationalEmail({
to: [user.email],
subject: "Code de verification - Portail Associations",
text: `Votre code de verification est ${code}. Il expire dans 10 minutes.`,
html: `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #161616;">
<h2>Code de verification</h2>
<p>Utilisez ce code pour finaliser votre connexion au Portail Associations :</p>
<p style="font-size: 28px; font-weight: 700; letter-spacing: 6px;">${code}</p>
<p>Ce code expire dans 10 minutes.</p>
</div>
`,
});
return new MfaChallengeRequiredError("email", challengeToken, expiresAt, maskEmail(user.email));
}
async function signSession(user: User) {
const expiresAt = Math.floor((Date.now() + ONE_YEAR_MS) / 1000);
return new SignJWT({
userId: user.id,
openId: user.openId,
} satisfies JwtPayload)
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(String(user.id))
.setExpirationTime(expiresAt)
.sign(getJwtSecret());
}
async function verifySession(token: string | undefined | null) {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, getJwtSecret(), {
algorithms: ["HS256"],
});
const userId = Number(payload.userId ?? payload.sub);
const openId = typeof payload.openId === "string" ? payload.openId : "";
if (!Number.isInteger(userId) || userId <= 0 || !openId) return null;
return { userId, openId };
} catch {
return null;
}
}
export function setSessionCookie(req: Request, res: Response, token: string) {
res.cookie(COOKIE_NAME, token, {
...getSessionCookieOptions(req),
maxAge: ONE_YEAR_MS,
});
}
export function clearSessionCookie(req: Request, res: Response) {
res.clearCookie(COOKIE_NAME, {
...getSessionCookieOptions(req),
maxAge: -1,
});
}
export async function registerLocalUser(input: {
name: string;
email: string;
password: string;
}) {
const email = normalizeEmail(input.email);
const existing = await db.getUserByEmail(email);
if (existing) {
throw new Error("Un compte existe deja avec cette adresse email");
}
const bootstrapUser = findBootstrapUserByCredentials(input);
if (bootstrapUser) {
return bootstrapUser;
}
const role =
ENV.adminEmail && normalizeEmail(ENV.adminEmail) === email ? "super_admin" : "user";
await db.upsertUser({
openId: email,
name: input.name.trim(),
email,
passwordHash: await hashPassword(input.password),
loginMethod: "local_jwt",
role,
lastSignedIn: new Date(),
});
const user = await db.getUserByEmail(email);
if (!user) throw new Error("Impossible de creer le compte utilisateur");
return user;
}
export async function loginLocalUser(input: { email: string; password: string }) {
const email = normalizeEmail(input.email);
const user = await db.getUserByEmail(email);
if (user) {
assertUserNotLocked(user);
if (!(await verifyPassword(input.password, user.passwordHash))) {
await registerLoginFailure(user);
throw ForbiddenError("Identifiants invalides");
}
await resetLoginSecurityState(user.id);
if (argon2Async && getPasswordHashAlgorithm(user.passwordHash) === "scrypt") {
await db.updateUser(user.id, {
passwordHash: await hashPassword(input.password),
});
}
const refreshedUser = (await db.getUserById(user.id)) ?? user;
if (getEffectiveMfaMethod(refreshedUser)) {
throw await sendLoginMfaChallenge(refreshedUser);
}
await db.upsertUser({
openId: user.openId,
lastSignedIn: new Date(),
});
return ensureConfiguredOwnerPrivileges((await db.getUserById(user.id)) ?? refreshedUser);
}
const bootstrapUser = findBootstrapUserByCredentials(input);
if (bootstrapUser) {
return bootstrapUser;
}
throw ForbiddenError("Identifiants invalides");
}
export async function verifyLocalUserMfa(input: { challengeToken: string; code: string }) {
const user = await db.getUserByMfaChallengeToken(input.challengeToken);
if (!user) {
throw ForbiddenError("Verification invalide ou expirée");
}
const effectiveMethod = getEffectiveMfaMethod(user);
if (!effectiveMethod) {
throw ForbiddenError("Verification invalide ou expirée");
}
if (user.mfaCodeExpiresAt && new Date(user.mfaCodeExpiresAt) < new Date()) {
await db.updateUser(user.id, {
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
});
throw ForbiddenError("Le code de verification a expiré");
}
if ((user.mfaCodeAttempts || 0) >= MFA_CODE_ATTEMPT_LIMIT) {
await db.updateUser(user.id, {
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
lockedUntil: new Date(Date.now() + LOGIN_LOCK_DURATION_MS),
});
throw new AccountLockedError(new Date(Date.now() + LOGIN_LOCK_DURATION_MS));
}
const isValidCode = effectiveMethod === "totp"
? verifyTotpCode(decryptSecuritySecret(user.mfaTotpSecretEncrypted), input.code)
: Boolean(user.mfaCodeHash) && hashOtpCode(input.code.trim()) === user.mfaCodeHash;
if (!isValidCode) {
await db.updateUser(user.id, {
mfaCodeAttempts: (user.mfaCodeAttempts || 0) + 1,
});
throw ForbiddenError("Code de verification incorrect");
}
await db.updateUser(user.id, {
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
lastSignedIn: new Date(),
});
return ensureConfiguredOwnerPrivileges((await db.getUserById(user.id)) ?? user);
}
export async function resendLocalUserMfaChallenge(challengeToken: string) {
const user = await db.getUserByMfaChallengeToken(challengeToken);
if (!user || getEffectiveMfaMethod(user) !== "email") {
throw ForbiddenError("Verification invalide ou expirée");
}
if (!user.email) {
throw ForbiddenError("Adresse email indisponible pour ce compte");
}
const code = generateOtpCode();
const expiresAt = new Date(Date.now() + MFA_CODE_TTL_MS);
await db.updateUser(user.id, {
mfaCodeHash: hashOtpCode(code),
mfaCodeExpiresAt: expiresAt,
mfaCodeAttempts: 0,
});
await sendOperationalEmail({
to: [user.email],
subject: "Nouveau code de verification - Portail Associations",
text: `Votre nouveau code de verification est ${code}. Il expire dans 10 minutes.`,
html: `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #161616;">
<h2>Nouveau code de verification</h2>
<p>Utilisez ce code pour finaliser votre connexion au Portail Associations :</p>
<p style="font-size: 28px; font-weight: 700; letter-spacing: 6px;">${code}</p>
<p>Ce code expire dans 10 minutes.</p>
</div>
`,
});
return {
challengeToken,
expiresAt,
maskedEmail: maskEmail(user.email),
};
}
export async function startAuthenticatorSetup(user: User) {
if (user.loginMethod !== "local_jwt" || !user.email) {
throw new Error("Authenticator est disponible uniquement pour les comptes locaux avec email.");
}
const secret = generateTotpSecret();
await db.updateUser(user.id, {
mfaTotpPendingSecretEncrypted: encryptSecuritySecret(secret),
});
return {
manualEntryKey: secret,
otpauthUrl: buildOtpAuthUrl(user.email, secret),
};
}
export async function confirmAuthenticatorSetup(user: User, code: string) {
const freshUser = await db.getUserById(user.id);
if (!freshUser) {
throw new Error("Compte introuvable");
}
const pendingSecret = decryptSecuritySecret(freshUser.mfaTotpPendingSecretEncrypted);
if (!pendingSecret) {
throw new Error("Aucune configuration Authenticator en attente.");
}
if (!verifyTotpCode(pendingSecret, code)) {
throw new Error("Code Authenticator invalide.");
}
await db.updateUser(user.id, {
mfaEnabled: true,
mfaMethod: "authenticator_app",
mfaTotpSecretEncrypted: encryptSecuritySecret(pendingSecret),
mfaTotpPendingSecretEncrypted: null,
});
}
export async function cancelAuthenticatorSetup(user: User) {
await db.updateUser(user.id, {
mfaTotpPendingSecretEncrypted: null,
});
}
export async function scheduleAccountDeletion(user: User) {
if (user.legalHold) {
throw new Error("Ce compte est actuellement place en conservation legale et ne peut pas etre supprime.");
}
const now = new Date();
const purgeAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
await db.updateUser(user.id, {
deletionRequestedAt: now,
purgeScheduledAt: purgeAt,
isActive: false,
});
return { deletionRequestedAt: now, purgeScheduledAt: purgeAt };
}
export async function cancelScheduledAccountDeletion(user: User) {
await db.updateUser(user.id, {
deletionRequestedAt: null,
purgeScheduledAt: null,
isActive: true,
});
}
export async function runUserPurgeScheduler() {
const startedAt = new Date();
const dueUsers = await db.getUsersScheduledForPurge();
let purgedUsers = 0;
for (const user of dueUsers) {
const association = await db.getAssociationByUserId(user.id);
if (association) {
await db.updateAssociation(association.id, {
sourceDirectoryEntryId: null,
nomAssociation: `Association supprimée #${association.id}`,
siret: null,
rna: null,
thematique: null,
adresse: null,
codePostal: null,
ville: null,
telephone: null,
emailContact: null,
siteWeb: null,
facebookUrl: null,
instagramUrl: null,
objetAssociation: null,
nomRepresentant: null,
fonctionRepresentant: null,
gouvernance: null,
profileComplete: false,
isActive: false,
});
}
await db.updateUser(user.id, {
openId: `purged:${user.id}:${Date.now()}`,
name: `Compte supprimé #${user.id}`,
email: null,
passwordHash: null,
isActive: false,
mfaEnabled: false,
mfaMethod: null,
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
mfaTotpSecretEncrypted: null,
mfaTotpPendingSecretEncrypted: null,
purgedAt: new Date(),
legalHold: false,
legalHoldReason: null,
});
await db.createAuditLog({
userId: SYSTEM_ACTOR_USER_ID,
action: "data_purge",
entityType: "user",
entityId: user.id,
details: JSON.stringify({
event: "DATA_PURGE",
executedAt: new Date().toISOString(),
status: "SUCCESS",
purgeScheduledAt: user.purgeScheduledAt,
}),
ipAddress: "system",
});
purgedUsers += 1;
}
const allUsers = await db.getAllUsers();
const pendingDeletionCount = allUsers.filter((item) => item.deletionRequestedAt && !item.purgedAt).length;
const purgedTotalCount = allUsers.filter((item) => item.purgedAt).length;
await db.setPortalSetting(
RETENTION_REPORT_SETTING_KEY,
JSON.stringify({
generatedAt: new Date().toISOString(),
startedAt: startedAt.toISOString(),
reportType: "daily-retention-job",
purgedUsers,
pendingDeletionCount,
purgedTotalCount,
}),
"Dernier rapport automatique de retention et purge"
);
return {
generatedAt: new Date().toISOString(),
purgedUsers,
pendingDeletionCount,
purgedTotalCount,
};
}
export function isAccountLockedError(error: unknown): error is AccountLockedError {
return error instanceof AccountLockedError;
}
export function isMfaCodeRequiredError(error: unknown): error is MfaChallengeRequiredError {
return error instanceof MfaChallengeRequiredError;
}
export async function loginOAuthUser(input: {
provider: "google" | "facebook";
providerUserId: string;
email: string;
name?: string | null;
}) {
const email = normalizeEmail(input.email);
const existingByEmail = await db.getUserByEmail(email);
const openId = existingByEmail?.openId || email || `${input.provider}:${input.providerUserId}`;
const role = existingByEmail?.role
?? (ENV.adminEmail && normalizeEmail(ENV.adminEmail) === email ? "super_admin" : "user");
await db.upsertUser({
openId,
name: input.name?.trim() || existingByEmail?.name || email.split("@")[0] || "Utilisateur",
email,
loginMethod: `${input.provider}_oauth`,
role,
lastSignedIn: new Date(),
});
if (existingByEmail) {
return ensureConfiguredOwnerPrivileges((await db.getUserById(existingByEmail.id)) ?? existingByEmail);
}
const user = await db.getUserByOpenId(openId);
if (!user) {
throw new Error("Impossible de finaliser la connexion sociale");
}
return ensureConfiguredOwnerPrivileges(user);
}
export async function createSessionToken(user: User) {
return signSession(user);
}
export async function authenticateRequest(req: Request): Promise<User> {
const cookies = parseCookies(req.headers.cookie);
const session = await verifySession(cookies.get(COOKIE_NAME));
if (!session) {
throw ForbiddenError("Invalid session cookie");
}
const user = await db.getUserById(session.userId);
const userByOpenId = user ? null : await db.getUserByOpenId(session.openId);
const bootstrapUser = findBootstrapUserByOpenId(session.openId);
if (!user && userByOpenId && userByOpenId.isActive) {
return ensureConfiguredOwnerPrivileges(userByOpenId);
}
if (!user && bootstrapUser) {
return bootstrapUser;
}
if (!user || user.openId !== session.openId || !user.isActive) {
throw ForbiddenError("User not found");
}
return ensureConfiguredOwnerPrivileges(user);
}