Ajout de la gestion des erreurs HTTP, implémentation de la validation des comptes, et mise à jour des dépendances. Création de tests pour les entités et services de compte, ainsi que l'ajout d'un système de limitation de taux.

This commit is contained in:
2025-08-12 18:29:05 +00:00
parent 14e69e1f61
commit 3fe9fc7142
18 changed files with 1774 additions and 64 deletions

View File

@ -4,6 +4,7 @@ import loadConfiguration from "./config";
import { DatabaseInterface } from "./database/DatabaseInterface";
import PgDatabase from "./database/PgDatabase";
import { setup as setupAccounts } from "./domain/account/setup";
import { HTTPError } from "./errors";
const config = loadConfiguration();
const database: DatabaseInterface = new PgDatabase(config.database);
@ -11,6 +12,14 @@ const app = new Hono();
app.route("/accounts", setupAccounts(database));
app.onError((err, c) => {
if (err instanceof HTTPError) {
return c.json({ error: err.message }, err.statusCode);
}
return c.json({ error: "Internal server error" }, 500);
});
serve({
port: config.port,
fetch: app.fetch,

View File

@ -1,15 +1,40 @@
import { Hono } from "hono";
import { validator } from "hono/validator";
import { AccountServiceInterface } from "../service/AccountServiceInterface";
import { loginSchema, registerSchema } from "../validation/AccountValidation";
import { BadSchemaError } from "../../../errors";
import { createRateLimit } from "../../../middleware/rateLimiter";
const loginRateLimit = createRateLimit({
windowMs: 15 * 60 * 1000,
maxAttempts: 5,
keyGenerator: (c) => {
const ip =
c.req.header("x-forwarded-for") ||
c.req.header("x-real-ip") ||
"127.0.0.1";
return `login:${ip}`;
},
});
export default function toRoutes(
accountService: AccountServiceInterface,
): Hono {
const app = new Hono();
app.post("/login", async (ctx) => {
try {
const { email, password } = await ctx.req.json();
app.post(
"/login",
loginRateLimit,
validator("json", (value) => {
const parsed = loginSchema.safeParse(value);
if (!parsed.success) {
throw new BadSchemaError(parsed.error.message);
}
return parsed.data;
}),
async (ctx) => {
const { email, password } = ctx.req.valid("json");
const account = await accountService.login(email, password);
return ctx.json({
@ -17,38 +42,30 @@ export default function toRoutes(
accountId: account.id,
email: account.email,
});
} catch (error) {
return ctx.json(
{
success: false,
error: error instanceof Error ? error.message : "Erreur inconnue",
},
400,
);
}
});
},
);
app.post("/register", async (ctx) => {
try {
const { email, password } = await ctx.req.json();
app.post(
"/register",
validator("json", (value) => {
const parsed = registerSchema.safeParse(value);
if (!parsed.success) {
throw new BadSchemaError(parsed.error.message);
}
const account = await accountService.createAccount(email, password);
return parsed.data;
}),
async (ctx) => {
const { email, password } = ctx.req.valid("json");
const account = await accountService.register(email, password);
return ctx.json({
success: true,
accountId: account.id,
email: account.email,
});
} catch (error) {
return ctx.json(
{
success: false,
error: error instanceof Error ? error.message : "Erreur inconnue",
},
400,
);
}
});
},
);
return app;
}

View File

@ -1,3 +1,13 @@
import bcrypt from "bcrypt";
import {
InvalidEmailFormatError,
WeakPasswordError,
} from "../errors/AccountErrors";
import {
EMAIL_REGEX,
MIN_PASSWORD_LENGTH,
} from "../validation/AccountValidation";
export class AccountEntity {
constructor(
public readonly id: string,
@ -8,34 +18,36 @@ export class AccountEntity {
public readonly updatedAt: Date,
) {
this.validateEmail(email);
this.validatePassword(password);
}
// Logique métier : validation de l'email
private validateEmail(email: string): void {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("Format d'email invalide");
if (!EMAIL_REGEX.test(email)) {
throw new InvalidEmailFormatError(email);
}
}
private validatePassword(password: string): void {
if (password.length < 8) {
throw new Error("Mot de passe trop court");
private static validatePassword(password: string): void {
if (password.length < MIN_PASSWORD_LENGTH) {
throw new WeakPasswordError();
}
}
// Logique métier : vérification du mot de passe
public verifyPassword(plainPassword: string): boolean {
// Dans un vrai projet, on utiliserait bcrypt
return this.password === plainPassword;
return bcrypt.compareSync(plainPassword, this.password);
}
public get hashedPassword(): string {
return this.password;
}
// Factory method pour créer un nouveau compte
static create(email: string, password: string): AccountEntity {
AccountEntity.validatePassword(password);
const now = new Date();
const id = crypto.randomUUID();
return new AccountEntity(id, email, password, 1, now, now);
const hashedPassword = bcrypt.hashSync(password, 10);
return new AccountEntity(id, email, hashedPassword, 1, now, now);
}
}

View File

@ -0,0 +1,44 @@
import { HTTPError } from "../../../errors";
import { MIN_PASSWORD_LENGTH } from "../validation/AccountValidation";
export class AccountNotFoundError extends HTTPError {
constructor(identifier: string) {
super(404, `Compte non trouvé : ${identifier}`);
}
}
export class AccountAlreadyExistsError extends HTTPError {
constructor(email: string) {
super(409, `Un compte avec cet email existe déjà : ${email}`);
}
}
export class BadPasswordError extends HTTPError {
constructor() {
super(401, "Mot de passe incorrect");
}
}
export class InvalidEmailFormatError extends HTTPError {
constructor(email: string) {
super(400, `Format d'email invalide : ${email}`);
}
}
export class WeakPasswordError extends HTTPError {
constructor() {
super(
400,
`Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères`,
);
}
}
export class TooManyAttemptsError extends HTTPError {
constructor(retryAfter: number) {
super(
429,
`Trop de tentatives de connexion. Réessayez dans ${retryAfter} secondes`,
);
}
}

View File

@ -2,6 +2,6 @@ import { AccountEntity } from "../entity/AccountEntity";
export interface AccountRepositoryInterface {
findByEmail(email: string): Promise<AccountEntity | null>;
save(account: AccountEntity): Promise<number>;
save(account: AccountEntity): Promise<string>;
findById(id: string): Promise<AccountEntity | null>;
}

View File

@ -6,21 +6,86 @@ export default class AccountRepository implements AccountRepositoryInterface {
constructor(private readonly database: DatabaseInterface) {}
async findByEmail(email: string): Promise<AccountEntity | null> {
// Implémentation simple pour l'exemple
// Dans un vrai projet, on ferait une requête à la base de données
console.log(`Recherche du compte avec email: ${email}`);
return null;
const sql = `
SELECT id, email, password, role_id, created_at, updated_at
FROM accounts
WHERE email = $1
`;
const result = await this.database.fetchOne<{
id: string;
email: string;
password: string;
role_id: number;
created_at: Date;
updated_at: Date;
}>(sql, [email]);
if (!result) {
return null;
}
return new AccountEntity(
result.id,
result.email,
result.password,
result.role_id,
result.created_at,
result.updated_at,
);
}
async save(account: AccountEntity): Promise<number> {
// Implémentation simple pour l'exemple
console.log(`Sauvegarde du compte: ${account.id}`);
return 1;
async save(account: AccountEntity): Promise<string> {
const sql = `
INSERT INTO accounts (id, email, password, role_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
password = EXCLUDED.password,
role_id = EXCLUDED.role_id,
updated_at = EXCLUDED.updated_at
RETURNING id
`;
const result = await this.database.fetchOne<{ id: string }>(sql, [
account.id,
account.email,
account.hashedPassword,
account.roleId,
account.createdAt,
account.updatedAt,
]);
return result!.id;
}
async findById(id: string): Promise<AccountEntity | null> {
// Implémentation simple pour l'exemple
console.log(`Recherche du compte avec ID: ${id}`);
return null;
const sql = `
SELECT id, email, password, role_id, created_at, updated_at
FROM accounts
WHERE id = $1
`;
const result = await this.database.fetchOne<{
id: string;
email: string;
password: string;
role_id: number;
created_at: Date;
updated_at: Date;
}>(sql, [id]);
if (!result) {
return null;
}
return new AccountEntity(
result.id,
result.email,
result.password,
result.role_id,
result.created_at,
result.updated_at,
);
}
}

View File

@ -1,36 +1,37 @@
import { AccountEntity } from "../entity/AccountEntity";
import { AccountRepositoryInterface } from "../repository/AccountRepositoryInterface";
import { AccountServiceInterface } from "./AccountServiceInterface";
import {
AccountNotFoundError,
AccountAlreadyExistsError,
BadPasswordError,
} from "../errors/AccountErrors";
export default class AccountService implements AccountServiceInterface {
constructor(private readonly accountRepository: AccountRepositoryInterface) {}
async login(email: string, password: string): Promise<AccountEntity> {
// Logique métier DDD : authentification
const account = await this.accountRepository.findByEmail(email);
if (!account) {
throw new Error("Compte non trouvé");
throw new AccountNotFoundError(email);
}
if (!account.verifyPassword(password)) {
throw new Error("Mot de passe incorrect");
throw new BadPasswordError();
}
return account;
}
async createAccount(email: string, password: string): Promise<AccountEntity> {
// Logique métier DDD : vérifier que l'email n'existe pas déjà
async register(email: string, password: string): Promise<AccountEntity> {
const existingAccount = await this.accountRepository.findByEmail(email);
if (existingAccount) {
throw new Error("Un compte avec cet email existe déjà");
throw new AccountAlreadyExistsError(email);
}
// Utilisation de la factory method de l'entité
const newAccount = AccountEntity.create(email, password);
await this.accountRepository.save(newAccount);
return newAccount;

View File

@ -2,5 +2,5 @@ import { AccountEntity } from "../entity/AccountEntity";
export interface AccountServiceInterface {
login(email: string, password: string): Promise<AccountEntity>;
createAccount(email: string, password: string): Promise<AccountEntity>;
register(email: string, password: string): Promise<AccountEntity>;
}

View File

@ -0,0 +1,25 @@
import { z } from "zod";
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const MIN_PASSWORD_LENGTH = 8;
export const emailSchema = z
.string()
.regex(EMAIL_REGEX, "Format d'email invalide");
export const passwordSchema = z
.string()
.min(
MIN_PASSWORD_LENGTH,
`Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères`,
);
export const loginSchema = z.object({
email: emailSchema,
password: passwordSchema,
});
export const registerSchema = z.object({
email: emailSchema,
password: passwordSchema,
});

17
src/errors.ts Normal file
View File

@ -0,0 +1,17 @@
import { ContentfulStatusCode } from "hono/utils/http-status";
export class HTTPError extends Error {
constructor(
public readonly statusCode: ContentfulStatusCode,
message: string,
parent?: Error,
) {
super(message, { cause: parent });
}
}
export class BadSchemaError extends HTTPError {
constructor(message: string, parent?: Error) {
super(400, message, parent);
}
}

View File

@ -0,0 +1,57 @@
import { Context, Next } from 'hono';
import { TooManyAttemptsError } from '../domain/account/errors/AccountErrors';
interface RateLimitConfig {
windowMs: number;
maxAttempts: number;
keyGenerator?: (c: Context) => string;
}
interface AttemptRecord {
count: number;
resetTime: number;
}
const attempts = new Map<string, AttemptRecord>();
function cleanupExpiredEntries(): void {
const now = Date.now();
for (const [key, record] of attempts.entries()) {
if (now > record.resetTime) {
attempts.delete(key);
}
}
}
export function createRateLimit(config: RateLimitConfig) {
const { windowMs, maxAttempts, keyGenerator = (c) => c.req.header('x-forwarded-for') || '127.0.0.1' } = config;
return async (c: Context, next: Next) => {
const key = keyGenerator(c);
const now = Date.now();
cleanupExpiredEntries();
const record = attempts.get(key);
if (!record) {
attempts.set(key, { count: 1, resetTime: now + windowMs });
await next();
return;
}
if (now > record.resetTime) {
attempts.set(key, { count: 1, resetTime: now + windowMs });
await next();
return;
}
if (record.count >= maxAttempts) {
const retryAfter = Math.ceil((record.resetTime - now) / 1000);
throw new TooManyAttemptsError(retryAfter);
}
record.count++;
await next();
};
}