962 lines
28 KiB
TypeScript
962 lines
28 KiB
TypeScript
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);
|
|
}
|