Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
962
server/_core/auth.ts
Normal file
962
server/_core/auth.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue