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:
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
44
src/domain/account/errors/AccountErrors.ts
Normal file
44
src/domain/account/errors/AccountErrors.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
}
|
@ -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>;
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
}
|
||||
|
25
src/domain/account/validation/AccountValidation.ts
Normal file
25
src/domain/account/validation/AccountValidation.ts
Normal 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
17
src/errors.ts
Normal 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);
|
||||
}
|
||||
}
|
57
src/middleware/rateLimiter.ts
Normal file
57
src/middleware/rateLimiter.ts
Normal 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();
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user