feat: secu migrate to user uuid
This commit is contained in:
116
MIGRATION_UUID.md
Normal file
116
MIGRATION_UUID.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Migration vers UUIDs pour la sécurité
|
||||||
|
|
||||||
|
## 🎯 Objectif
|
||||||
|
|
||||||
|
Remplacer les user IDs séquentiels (1, 2, 3...) par des UUIDs pour éviter les attaques d'énumération.
|
||||||
|
|
||||||
|
## ⚠️ Important
|
||||||
|
|
||||||
|
Cette migration doit être effectuée quand l'application n'est pas en production ou pendant une maintenance.
|
||||||
|
|
||||||
|
## 📋 Étapes à suivre
|
||||||
|
|
||||||
|
### Scenario A : Nouvelle installation (recommandé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Supprimer l'ancienne DB si elle existe
|
||||||
|
dropdb peakskills
|
||||||
|
|
||||||
|
# 2. Créer une nouvelle DB avec le nouveau schema UUID
|
||||||
|
createdb peakskills
|
||||||
|
psql -h localhost -U peakskills_user -d peakskills -f scripts/init.sql
|
||||||
|
|
||||||
|
# 3. Migrer les données skills
|
||||||
|
npm run migrate-skills
|
||||||
|
|
||||||
|
# 4. Démarrer l'app
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario B : Migration base existante
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Se connecter à PostgreSQL
|
||||||
|
psql -h localhost -U peakskills_user -d peakskills
|
||||||
|
|
||||||
|
# 2. Exécuter le script de migration
|
||||||
|
\i scripts/migrate-to-uuid.sql
|
||||||
|
|
||||||
|
# 3. Vérifier la migration
|
||||||
|
SELECT id, uuid_id, first_name, last_name FROM users LIMIT 5;
|
||||||
|
|
||||||
|
# 4. Redémarrer l'app
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Nettoyer les anciennes sessions
|
||||||
|
|
||||||
|
- Tous les utilisateurs devront se reconnecter (car les cookies utilisent maintenant des UUIDs)
|
||||||
|
- C'est normal et nécessaire pour la sécurité
|
||||||
|
|
||||||
|
## 🔒 Sécurité apportée
|
||||||
|
|
||||||
|
**Avant :**
|
||||||
|
|
||||||
|
- Cookie: `peakSkills_userId=2`
|
||||||
|
- Facilement hackable: essayer 1, 3, 4, 5...
|
||||||
|
|
||||||
|
**Après :**
|
||||||
|
|
||||||
|
- Cookie: `peakSkills_userId=a1b2c3d4-e5f6-7890-abcd-ef1234567890`
|
||||||
|
- Impossible à deviner: UUID v4 avec 2^122 possibilités
|
||||||
|
|
||||||
|
## 🚀 Tests à effectuer
|
||||||
|
|
||||||
|
1. **Login nouveau utilisateur**
|
||||||
|
|
||||||
|
- Créer un compte → doit générer un UUID
|
||||||
|
- Vérifier le cookie dans le navigateur
|
||||||
|
|
||||||
|
2. **Utilisateur existant**
|
||||||
|
|
||||||
|
- Se reconnecter → doit utiliser l'UUID existant
|
||||||
|
- Vérifier que les données sont préservées
|
||||||
|
|
||||||
|
3. **Actions d'évaluation**
|
||||||
|
- Modifier une skill → doit fonctionner avec UUID
|
||||||
|
- Vérifier que les données sont sauvées
|
||||||
|
|
||||||
|
## 📊 Migration status
|
||||||
|
|
||||||
|
- ✅ **Code application complètement adapté aux UUIDs**
|
||||||
|
- ✅ Nouvelles méthodes `*Uuid()` dans EvaluationService
|
||||||
|
- ✅ Toutes les APIs modifiées (`/api/auth`, `/api/evaluations`, `/api/evaluations/skills`)
|
||||||
|
- ✅ Functions SSR adaptées (server-auth.ts)
|
||||||
|
- ✅ Middleware mis à jour
|
||||||
|
- ✅ Types AuthService corrigés
|
||||||
|
- ⏳ **À faire : Exécuter la migration DB**
|
||||||
|
- ⏳ **À faire : Tester en développement**
|
||||||
|
- ⏳ **À faire : Nettoyer le code legacy une fois validé**
|
||||||
|
|
||||||
|
## 🔧 Fichiers modifiés
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
- `services/evaluation-service.ts` → Méthodes `*Uuid()` ajoutées
|
||||||
|
- `lib/server-auth.ts` → `getUserUuidFromCookie()` et adaptations
|
||||||
|
- `lib/auth-utils.ts` → Types de retour UUID
|
||||||
|
|
||||||
|
### APIs
|
||||||
|
|
||||||
|
- `app/api/auth/route.ts` → Cookies UUID
|
||||||
|
- `app/api/evaluations/route.ts` → `getUserByUuid()`
|
||||||
|
- `app/api/evaluations/skills/route.ts` → Toutes méthodes `*Uuid()`
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- `middleware.ts` → Variables UUID
|
||||||
|
- `scripts/migrate-to-uuid.sql` → Schema DB migration (pour existants)
|
||||||
|
- `scripts/init.sql` → Schema DB initial avec UUIDs (pour nouvelles installs)
|
||||||
|
|
||||||
|
## 🧹 Nettoyage futur
|
||||||
|
|
||||||
|
Une fois la migration validée, supprimer :
|
||||||
|
|
||||||
|
- Méthodes `upsertUser()` et `getUserById()` legacy
|
||||||
|
- Colonnes `id` et `user_id` dans les tables (garder seulement UUIDs)
|
||||||
@@ -12,14 +12,14 @@ const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 jours
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const userId = cookieStore.get(COOKIE_NAME)?.value;
|
const userUuid = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userUuid) {
|
||||||
return NextResponse.json({ user: null }, { status: 200 });
|
return NextResponse.json({ user: null }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const evaluationService = new EvaluationService();
|
const evaluationService = new EvaluationService();
|
||||||
const userProfile = await evaluationService.getUserById(parseInt(userId));
|
const userProfile = await evaluationService.getUserByUuid(userUuid);
|
||||||
|
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
// Cookie invalide, le supprimer
|
// Cookie invalide, le supprimer
|
||||||
@@ -44,7 +44,7 @@ export async function GET() {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const profile: UserProfile = await request.json();
|
const profile: UserProfile = await request.json();
|
||||||
|
|
||||||
if (!profile.firstName || !profile.lastName || !profile.teamId) {
|
if (!profile.firstName || !profile.lastName || !profile.teamId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Missing required fields" },
|
{ error: "Missing required fields" },
|
||||||
@@ -53,16 +53,19 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const evaluationService = new EvaluationService();
|
const evaluationService = new EvaluationService();
|
||||||
const userId = await evaluationService.upsertUser(profile);
|
const userUuid = await evaluationService.upsertUserUuid(profile);
|
||||||
|
|
||||||
// Créer la réponse avec le cookie
|
// Créer la réponse avec le cookie
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json(
|
||||||
user: { ...profile, id: userId },
|
{
|
||||||
userId
|
user: { ...profile, uuid: userUuid },
|
||||||
}, { status: 200 });
|
userUuid,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
|
||||||
// Définir le cookie avec l'ID utilisateur
|
// Définir le cookie avec l'UUID utilisateur (plus sécurisé)
|
||||||
response.cookies.set(COOKIE_NAME, userId.toString(), {
|
response.cookies.set(COOKIE_NAME, userUuid, {
|
||||||
maxAge: COOKIE_MAX_AGE,
|
maxAge: COOKIE_MAX_AGE,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
@@ -90,9 +93,6 @@ export async function DELETE() {
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error logging out user:", error);
|
console.error("Error logging out user:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Failed to logout" }, { status: 500 });
|
||||||
{ error: "Failed to logout" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import { COOKIE_NAME } from "@/lib/auth-utils";
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const userId = cookieStore.get(COOKIE_NAME)?.value;
|
const userUuid = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
const userIdNum = userId ? parseInt(userId) : null;
|
|
||||||
|
|
||||||
// Support pour l'ancien mode avec paramètres (pour la compatibilité)
|
// Support pour l'ancien mode avec paramètres (pour la compatibilité)
|
||||||
if (!userIdNum) {
|
if (!userUuid) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const firstName = searchParams.get("firstName");
|
const firstName = searchParams.get("firstName");
|
||||||
const lastName = searchParams.get("lastName");
|
const lastName = searchParams.get("lastName");
|
||||||
@@ -29,8 +28,8 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ evaluation });
|
return NextResponse.json({ evaluation });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode authentifié par cookie
|
// Mode authentifié par cookie UUID
|
||||||
const userProfile = await evaluationService.getUserById(userIdNum);
|
const userProfile = await evaluationService.getUserByUuid(userUuid);
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Utilisateur non trouvé" },
|
{ error: "Utilisateur non trouvé" },
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ const COOKIE_NAME = "peakSkills_userId";
|
|||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Récupérer l'utilisateur depuis le cookie
|
// Récupérer l'utilisateur depuis le cookie (maintenant un UUID)
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const userId = cookieStore.get(COOKIE_NAME)?.value;
|
const userUuid = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userUuid) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Utilisateur non authentifié" },
|
{ error: "Utilisateur non authentifié" },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userProfile = await evaluationService.getUserById(parseInt(userId));
|
const userProfile = await evaluationService.getUserByUuid(userUuid);
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Utilisateur introuvable" },
|
{ error: "Utilisateur introuvable" },
|
||||||
@@ -44,7 +44,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await evaluationService.updateSkillLevel(
|
await evaluationService.updateSkillLevelUuid(
|
||||||
userProfile,
|
userProfile,
|
||||||
category,
|
category,
|
||||||
skillId,
|
skillId,
|
||||||
@@ -59,7 +59,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await evaluationService.updateSkillMentorStatus(
|
await evaluationService.updateSkillMentorStatusUuid(
|
||||||
userProfile,
|
userProfile,
|
||||||
category,
|
category,
|
||||||
skillId,
|
skillId,
|
||||||
@@ -74,7 +74,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await evaluationService.updateSkillLearningStatus(
|
await evaluationService.updateSkillLearningStatusUuid(
|
||||||
userProfile,
|
userProfile,
|
||||||
category,
|
category,
|
||||||
skillId,
|
skillId,
|
||||||
@@ -83,7 +83,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "addSkill":
|
case "addSkill":
|
||||||
await evaluationService.addSkillToEvaluation(
|
await evaluationService.addSkillToEvaluationUuid(
|
||||||
userProfile,
|
userProfile,
|
||||||
category,
|
category,
|
||||||
skillId
|
skillId
|
||||||
@@ -91,7 +91,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "removeSkill":
|
case "removeSkill":
|
||||||
await evaluationService.removeSkillFromEvaluation(
|
await evaluationService.removeSkillFromEvaluationUuid(
|
||||||
userProfile,
|
userProfile,
|
||||||
category,
|
category,
|
||||||
skillId
|
skillId
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import {
|
|||||||
getServerSkillCategories,
|
getServerSkillCategories,
|
||||||
getServerTeams,
|
getServerTeams,
|
||||||
} from "@/lib/server-auth";
|
} from "@/lib/server-auth";
|
||||||
import {
|
import { EvaluationClientWrapper } from "@/components/evaluation";
|
||||||
EvaluationClientWrapper,
|
|
||||||
WelcomeEvaluationScreen,
|
|
||||||
} from "@/components/evaluation";
|
|
||||||
import { SkillEvaluation } from "@/components/skill-evaluation";
|
import { SkillEvaluation } from "@/components/skill-evaluation";
|
||||||
|
|
||||||
export default async function EvaluationPage() {
|
export default async function EvaluationPage() {
|
||||||
@@ -27,22 +24,14 @@ export default async function EvaluationPage() {
|
|||||||
getServerTeams(),
|
getServerTeams(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Si pas d'évaluation, afficher l'écran d'accueil évaluation
|
|
||||||
if (!userEvaluation) {
|
|
||||||
return <WelcomeEvaluationScreen teams={teams} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EvaluationClientWrapper userEvaluation={userEvaluation} teams={teams}>
|
<EvaluationClientWrapper userEvaluation={userEvaluation} teams={teams}>
|
||||||
<div>
|
<div>
|
||||||
{/* Skill Evaluation */}
|
{/* Skill Evaluation */}
|
||||||
{skillCategories.length > 0 &&
|
<SkillEvaluation
|
||||||
userEvaluation.evaluations.length > 0 && (
|
categories={skillCategories}
|
||||||
<SkillEvaluation
|
evaluations={userEvaluation?.evaluations || []}
|
||||||
categories={skillCategories}
|
/>
|
||||||
evaluations={userEvaluation.evaluations}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</EvaluationClientWrapper>
|
</EvaluationClientWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/lib/evaluation-actions";
|
} from "@/lib/evaluation-actions";
|
||||||
|
|
||||||
interface EvaluationClientWrapperProps {
|
interface EvaluationClientWrapperProps {
|
||||||
userEvaluation: UserEvaluation;
|
userEvaluation: UserEvaluation | null;
|
||||||
teams: Team[];
|
teams: Team[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
static async login(
|
static async login(
|
||||||
profile: UserProfile
|
profile: UserProfile
|
||||||
): Promise<{ user: UserProfile & { id: number }; userId: number }> {
|
): Promise<{ user: UserProfile & { uuid: string }; userUuid: string }> {
|
||||||
const response = await fetch("/api/auth", {
|
const response = await fetch("/api/auth", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -6,7 +6,21 @@ import { SkillsService } from "@/services/skills-service";
|
|||||||
import { SkillCategory, Team } from "./types";
|
import { SkillCategory, Team } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère l'ID utilisateur depuis le cookie côté serveur
|
* Récupère l'UUID utilisateur depuis le cookie côté serveur
|
||||||
|
*/
|
||||||
|
export async function getUserUuidFromCookie(): Promise<string | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const userUuidCookie = cookieStore.get("peakSkills_userId");
|
||||||
|
|
||||||
|
if (!userUuidCookie?.value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return userUuidCookie.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère l'ID utilisateur depuis le cookie côté serveur (legacy)
|
||||||
*/
|
*/
|
||||||
export async function getUserIdFromCookie(): Promise<number | null> {
|
export async function getUserIdFromCookie(): Promise<number | null> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@@ -16,6 +30,7 @@ export async function getUserIdFromCookie(): Promise<number | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Essayer de parser comme number pour backward compatibility
|
||||||
const userId = parseInt(userIdCookie.value);
|
const userId = parseInt(userIdCookie.value);
|
||||||
return isNaN(userId) ? null : userId;
|
return isNaN(userId) ? null : userId;
|
||||||
}
|
}
|
||||||
@@ -24,17 +39,17 @@ export async function getUserIdFromCookie(): Promise<number | null> {
|
|||||||
* Récupère l'évaluation complète de l'utilisateur côté serveur
|
* Récupère l'évaluation complète de l'utilisateur côté serveur
|
||||||
*/
|
*/
|
||||||
export async function getServerUserEvaluation() {
|
export async function getServerUserEvaluation() {
|
||||||
const userId = await getUserIdFromCookie();
|
const userUuid = await getUserUuidFromCookie();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userUuid) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const evaluationService = new EvaluationService();
|
const evaluationService = new EvaluationService();
|
||||||
|
|
||||||
// Récupérer d'abord le profil utilisateur
|
// Récupérer d'abord le profil utilisateur via UUID
|
||||||
const userProfile = await evaluationService.getUserById(userId);
|
const userProfile = await evaluationService.getUserByUuid(userUuid);
|
||||||
|
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
return null;
|
return null;
|
||||||
@@ -80,6 +95,6 @@ export async function getServerTeams(): Promise<Team[]> {
|
|||||||
* Vérifie simplement si l'utilisateur est authentifié via le cookie
|
* Vérifie simplement si l'utilisateur est authentifié via le cookie
|
||||||
*/
|
*/
|
||||||
export async function isUserAuthenticated(): Promise<boolean> {
|
export async function isUserAuthenticated(): Promise<boolean> {
|
||||||
const userId = await getUserIdFromCookie();
|
const userUuid = await getUserUuidFromCookie();
|
||||||
return !!userId;
|
return !!userUuid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,16 @@ export function middleware(request: NextRequest) {
|
|||||||
if (
|
if (
|
||||||
pathname.includes("/_next/") ||
|
pathname.includes("/_next/") ||
|
||||||
pathname.includes("/favicon.ico") ||
|
pathname.includes("/favicon.ico") ||
|
||||||
pathname.includes("/public/")
|
pathname.includes("/public/") ||
|
||||||
|
pathname.includes("/api/skills/migrate")
|
||||||
) {
|
) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier le cookie d'authentification
|
// Vérifier le cookie d'authentification (maintenant un UUID)
|
||||||
const userId = request.cookies.get(COOKIE_NAME)?.value;
|
const userUuid = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userUuid) {
|
||||||
// Rediriger vers la page de login si pas authentifié
|
// Rediriger vers la page de login si pas authentifié
|
||||||
const loginUrl = new URL("/login", request.url);
|
const loginUrl = new URL("/login", request.url);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
-- Enable UUID extension for secure user identification
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
-- Create enum for skill levels
|
-- Create enum for skill levels
|
||||||
CREATE TYPE skill_level_enum AS ENUM ('never', 'not-autonomous', 'autonomous', 'expert');
|
CREATE TYPE skill_level_enum AS ENUM ('never', 'not-autonomous', 'autonomous', 'expert');
|
||||||
|
|
||||||
@@ -38,15 +41,19 @@ CREATE TABLE skill_links (
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Users table
|
-- Users table with UUID primary key for security
|
||||||
|
-- UUIDs prevent user enumeration attacks (vs sequential IDs 1,2,3...)
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL, -- Legacy ID kept for backward compatibility during migration
|
||||||
|
uuid_id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||||
first_name VARCHAR(100) NOT NULL,
|
first_name VARCHAR(100) NOT NULL,
|
||||||
last_name VARCHAR(100) NOT NULL,
|
last_name VARCHAR(100) NOT NULL,
|
||||||
team_id VARCHAR(50) REFERENCES teams(id),
|
team_id VARCHAR(50) REFERENCES teams(id),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(first_name, last_name, team_id)
|
PRIMARY KEY (uuid_id),
|
||||||
|
UNIQUE(first_name, last_name, team_id),
|
||||||
|
UNIQUE(uuid_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ========================================
|
-- ========================================
|
||||||
@@ -56,10 +63,11 @@ CREATE TABLE users (
|
|||||||
-- User evaluations - metadata about user's evaluation session
|
-- User evaluations - metadata about user's evaluation session
|
||||||
CREATE TABLE user_evaluations (
|
CREATE TABLE user_evaluations (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER, -- Legacy column kept for backward compatibility
|
||||||
|
user_uuid UUID REFERENCES users(uuid_id) ON DELETE CASCADE,
|
||||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(user_id)
|
UNIQUE(user_uuid)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Skill evaluations - direct relationship between user and skill
|
-- Skill evaluations - direct relationship between user and skill
|
||||||
@@ -93,7 +101,9 @@ CREATE INDEX idx_skills_category_id ON skills(category_id);
|
|||||||
CREATE INDEX idx_skill_links_skill_id ON skill_links(skill_id);
|
CREATE INDEX idx_skill_links_skill_id ON skill_links(skill_id);
|
||||||
CREATE INDEX idx_users_team_id ON users(team_id);
|
CREATE INDEX idx_users_team_id ON users(team_id);
|
||||||
CREATE INDEX idx_users_unique_person ON users(first_name, last_name, team_id);
|
CREATE INDEX idx_users_unique_person ON users(first_name, last_name, team_id);
|
||||||
CREATE INDEX idx_user_evaluations_user_id ON user_evaluations(user_id);
|
CREATE INDEX idx_users_uuid_id ON users(uuid_id); -- Index on UUID for performance
|
||||||
|
CREATE INDEX idx_user_evaluations_user_uuid ON user_evaluations(user_uuid);
|
||||||
|
CREATE INDEX idx_user_evaluations_user_id ON user_evaluations(user_id); -- Legacy index
|
||||||
CREATE INDEX idx_skill_evaluations_user_evaluation_id ON skill_evaluations(user_evaluation_id);
|
CREATE INDEX idx_skill_evaluations_user_evaluation_id ON skill_evaluations(user_evaluation_id);
|
||||||
CREATE INDEX idx_skill_evaluations_skill_id ON skill_evaluations(skill_id);
|
CREATE INDEX idx_skill_evaluations_skill_id ON skill_evaluations(skill_id);
|
||||||
CREATE INDEX idx_skill_evaluations_is_selected ON skill_evaluations(is_selected);
|
CREATE INDEX idx_skill_evaluations_is_selected ON skill_evaluations(is_selected);
|
||||||
@@ -138,7 +148,7 @@ SELECT
|
|||||||
se.updated_at
|
se.updated_at
|
||||||
FROM users u
|
FROM users u
|
||||||
JOIN teams t ON u.team_id = t.id
|
JOIN teams t ON u.team_id = t.id
|
||||||
JOIN user_evaluations ue ON u.id = ue.user_id
|
JOIN user_evaluations ue ON u.uuid_id = ue.user_uuid
|
||||||
JOIN skill_evaluations se ON ue.id = se.user_evaluation_id
|
JOIN skill_evaluations se ON ue.id = se.user_evaluation_id
|
||||||
JOIN skills s ON se.skill_id = s.id
|
JOIN skills s ON se.skill_id = s.id
|
||||||
JOIN skill_categories sc ON s.category_id = sc.id;
|
JOIN skill_categories sc ON s.category_id = sc.id;
|
||||||
|
|||||||
40
scripts/migrate-to-uuid.sql
Normal file
40
scripts/migrate-to-uuid.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- Migration script: Replace sequential user IDs with UUIDs for security
|
||||||
|
-- This prevents enumeration attacks and improves security
|
||||||
|
|
||||||
|
-- Step 1: Enable UUID extension if not already enabled
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Step 2: Add new UUID column to users table
|
||||||
|
ALTER TABLE users ADD COLUMN uuid_id UUID DEFAULT uuid_generate_v4();
|
||||||
|
|
||||||
|
-- Step 3: Update all existing users to have UUIDs (they will be auto-generated)
|
||||||
|
UPDATE users SET uuid_id = uuid_generate_v4() WHERE uuid_id IS NULL;
|
||||||
|
|
||||||
|
-- Step 4: Make UUID column NOT NULL
|
||||||
|
ALTER TABLE users ALTER COLUMN uuid_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- Step 5: Add new UUID column to user_evaluations table
|
||||||
|
ALTER TABLE user_evaluations ADD COLUMN user_uuid UUID;
|
||||||
|
|
||||||
|
-- Step 6: Update user_evaluations to use the new UUIDs
|
||||||
|
UPDATE user_evaluations
|
||||||
|
SET user_uuid = users.uuid_id
|
||||||
|
FROM users
|
||||||
|
WHERE user_evaluations.user_id = users.id;
|
||||||
|
|
||||||
|
-- Step 7: Make user_uuid NOT NULL
|
||||||
|
ALTER TABLE user_evaluations ALTER COLUMN user_uuid SET NOT NULL;
|
||||||
|
|
||||||
|
-- Step 8: Add new UUID column to skill_evaluations (via user_evaluations)
|
||||||
|
-- No direct change needed as skill_evaluations references user_evaluations.id
|
||||||
|
|
||||||
|
-- Step 9: Create unique constraint on UUID
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_uuid_unique UNIQUE (uuid_id);
|
||||||
|
|
||||||
|
-- Step 10: Add unique constraint and foreign key for user_evaluations
|
||||||
|
ALTER TABLE user_evaluations ADD CONSTRAINT user_evaluations_user_uuid_unique UNIQUE (user_uuid);
|
||||||
|
ALTER TABLE user_evaluations ADD CONSTRAINT fk_user_evaluations_user_uuid
|
||||||
|
FOREIGN KEY (user_uuid) REFERENCES users(uuid_id);
|
||||||
|
|
||||||
|
-- Note: The actual switchover will be done in the application code
|
||||||
|
-- The old id columns will be kept temporarily for backward compatibility
|
||||||
@@ -9,7 +9,82 @@ import {
|
|||||||
|
|
||||||
export class EvaluationService {
|
export class EvaluationService {
|
||||||
/**
|
/**
|
||||||
* Crée ou met à jour un utilisateur
|
* Crée ou met à jour un utilisateur et retourne son UUID
|
||||||
|
*/
|
||||||
|
async upsertUserUuid(profile: UserProfile): Promise<string> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier si l'utilisateur existe déjà (par firstName + lastName + teamId)
|
||||||
|
const existingUserQuery = `
|
||||||
|
SELECT uuid_id FROM users
|
||||||
|
WHERE first_name = $1 AND last_name = $2 AND team_id = $3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const existingUser = await client.query(existingUserQuery, [
|
||||||
|
profile.firstName,
|
||||||
|
profile.lastName,
|
||||||
|
profile.teamId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (existingUser.rows.length > 0) {
|
||||||
|
// Retourner l'UUID de l'utilisateur existant
|
||||||
|
return existingUser.rows[0].uuid_id;
|
||||||
|
} else {
|
||||||
|
// Créer un nouvel utilisateur avec UUID auto-généré
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO users (first_name, last_name, team_id, uuid_id)
|
||||||
|
VALUES ($1, $2, $3, uuid_generate_v4())
|
||||||
|
RETURNING uuid_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await client.query(insertQuery, [
|
||||||
|
profile.firstName,
|
||||||
|
profile.lastName,
|
||||||
|
profile.teamId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.rows[0].uuid_id;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un utilisateur par son UUID
|
||||||
|
*/
|
||||||
|
async getUserByUuid(userUuid: string): Promise<UserProfile | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT u.first_name, u.last_name, u.team_id
|
||||||
|
FROM users u
|
||||||
|
WHERE u.uuid_id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await client.query(query, [userUuid]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.rows[0];
|
||||||
|
return {
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
teamId: user.team_id,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée ou met à jour un utilisateur (legacy - retourne l'ID numérique)
|
||||||
*/
|
*/
|
||||||
async upsertUser(profile: UserProfile): Promise<number> {
|
async upsertUser(profile: UserProfile): Promise<number> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
@@ -98,7 +173,50 @@ export class EvaluationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sauvegarde une évaluation utilisateur complète
|
* Sauvegarde une évaluation utilisateur complète (version UUID)
|
||||||
|
*/
|
||||||
|
async saveUserEvaluationUuid(evaluation: UserEvaluation): Promise<void> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. Upsert user avec UUID
|
||||||
|
const userUuid = await this.upsertUserUuid(evaluation.profile);
|
||||||
|
|
||||||
|
// 2. Upsert user_evaluation avec user_uuid
|
||||||
|
const userEvalQuery = `
|
||||||
|
INSERT INTO user_evaluations (user_uuid, last_updated)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_uuid)
|
||||||
|
DO UPDATE SET last_updated = $2
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const userEvalResult = await client.query(userEvalQuery, [
|
||||||
|
userUuid,
|
||||||
|
new Date(evaluation.lastUpdated),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userEvaluationId = userEvalResult.rows[0].id;
|
||||||
|
|
||||||
|
// 3. Sauvegarde chaque catégorie d'évaluation
|
||||||
|
for (const catEval of evaluation.evaluations) {
|
||||||
|
await this.saveSkillEvaluations(client, userEvaluationId, catEval);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde une évaluation utilisateur complète (legacy)
|
||||||
*/
|
*/
|
||||||
async saveUserEvaluation(evaluation: UserEvaluation): Promise<void> {
|
async saveUserEvaluation(evaluation: UserEvaluation): Promise<void> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
@@ -208,7 +326,7 @@ export class EvaluationService {
|
|||||||
const userQuery = `
|
const userQuery = `
|
||||||
SELECT u.*, ue.id as user_evaluation_id, ue.last_updated
|
SELECT u.*, ue.id as user_evaluation_id, ue.last_updated
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN user_evaluations ue ON u.id = ue.user_id
|
LEFT JOIN user_evaluations ue ON u.uuid_id = ue.user_uuid
|
||||||
WHERE u.first_name = $1 AND u.last_name = $2 AND u.team_id = $3
|
WHERE u.first_name = $1 AND u.last_name = $2 AND u.team_id = $3
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -293,7 +411,128 @@ export class EvaluationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Met à jour le niveau d'une skill
|
* Met à jour le niveau d'une skill (version UUID)
|
||||||
|
*/
|
||||||
|
async updateSkillLevelUuid(
|
||||||
|
profile: UserProfile,
|
||||||
|
category: string,
|
||||||
|
skillId: string,
|
||||||
|
level: SkillLevel
|
||||||
|
): Promise<void> {
|
||||||
|
await this.updateSkillPropertyUuid(
|
||||||
|
profile,
|
||||||
|
category,
|
||||||
|
skillId,
|
||||||
|
"level",
|
||||||
|
level
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour le statut mentor d'une skill (version UUID)
|
||||||
|
*/
|
||||||
|
async updateSkillMentorStatusUuid(
|
||||||
|
profile: UserProfile,
|
||||||
|
category: string,
|
||||||
|
skillId: string,
|
||||||
|
canMentor: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
await this.updateSkillPropertyUuid(
|
||||||
|
profile,
|
||||||
|
category,
|
||||||
|
skillId,
|
||||||
|
"can_mentor",
|
||||||
|
canMentor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour le statut apprentissage d'une skill (version UUID)
|
||||||
|
*/
|
||||||
|
async updateSkillLearningStatusUuid(
|
||||||
|
profile: UserProfile,
|
||||||
|
category: string,
|
||||||
|
skillId: string,
|
||||||
|
wantsToLearn: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
await this.updateSkillPropertyUuid(
|
||||||
|
profile,
|
||||||
|
category,
|
||||||
|
skillId,
|
||||||
|
"wants_to_learn",
|
||||||
|
wantsToLearn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Méthode utilitaire pour mettre à jour une propriété de skill (version UUID)
|
||||||
|
*/
|
||||||
|
private async updateSkillPropertyUuid(
|
||||||
|
profile: UserProfile,
|
||||||
|
category: string,
|
||||||
|
skillId: string,
|
||||||
|
property: string,
|
||||||
|
value: any
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const userUuid = await this.upsertUserUuid(profile);
|
||||||
|
|
||||||
|
// Upsert user_evaluation avec user_uuid
|
||||||
|
const userEvalResult = await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO user_evaluations (user_uuid, last_updated)
|
||||||
|
VALUES ($1, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (user_uuid)
|
||||||
|
DO UPDATE SET last_updated = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[userUuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
const userEvaluationId = userEvalResult.rows[0].id;
|
||||||
|
|
||||||
|
// Upsert skill evaluation avec gestion conditionnelle du level
|
||||||
|
let updateQuery: string;
|
||||||
|
let queryParams: any[];
|
||||||
|
|
||||||
|
if (property === "level") {
|
||||||
|
// Si on met à jour le level, utiliser la valeur fournie
|
||||||
|
updateQuery = `
|
||||||
|
INSERT INTO skill_evaluations (user_evaluation_id, skill_id, level, is_selected)
|
||||||
|
VALUES ($1, $2, $3, true)
|
||||||
|
ON CONFLICT (user_evaluation_id, skill_id)
|
||||||
|
DO UPDATE SET level = $3, is_selected = true
|
||||||
|
`;
|
||||||
|
queryParams = [userEvaluationId, skillId, value];
|
||||||
|
} else {
|
||||||
|
// Si on met à jour une autre propriété, level par défaut = 'never'
|
||||||
|
updateQuery = `
|
||||||
|
INSERT INTO skill_evaluations (user_evaluation_id, skill_id, level, ${property}, is_selected)
|
||||||
|
VALUES ($1, $2, 'never', $3, true)
|
||||||
|
ON CONFLICT (user_evaluation_id, skill_id)
|
||||||
|
DO UPDATE SET ${property} = $3, is_selected = true
|
||||||
|
`;
|
||||||
|
queryParams = [userEvaluationId, skillId, value];
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(updateQuery, queryParams);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour le niveau d'une skill (legacy)
|
||||||
*/
|
*/
|
||||||
async updateSkillLevel(
|
async updateSkillLevel(
|
||||||
profile: UserProfile,
|
profile: UserProfile,
|
||||||
@@ -407,7 +646,60 @@ export class EvaluationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ajoute une skill à l'évaluation
|
* Ajoute une skill à l'évaluation (version UUID)
|
||||||
|
*/
|
||||||
|
async addSkillToEvaluationUuid(
|
||||||
|
profile: UserProfile,
|
||||||
|
category: string,
|
||||||
|
skillId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.updateSkillPropertyUuid(
|
||||||
|
profile,
|
||||||
|
category,
|
||||||
|
skillId,
|
||||||
|
"level",
|
||||||
|
"never" // Valeur par défaut pour une skill nouvellement sélectionnée
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une skill de l'évaluation (version UUID)
|
||||||
|
*/
|
||||||
|
async removeSkillFromEvaluationUuid(
|
||||||
|
profile: UserProfile,
|
||||||
|
category: string,
|
||||||
|
skillId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const userUuid = await this.upsertUserUuid(profile);
|
||||||
|
|
||||||
|
// Supprimer directement la skill evaluation
|
||||||
|
const deleteQuery = `
|
||||||
|
DELETE FROM skill_evaluations
|
||||||
|
WHERE user_evaluation_id = (
|
||||||
|
SELECT id FROM user_evaluations WHERE user_uuid = $1
|
||||||
|
)
|
||||||
|
AND skill_id = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
await client.query(deleteQuery, [userUuid, skillId]);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute une skill à l'évaluation (legacy)
|
||||||
*/
|
*/
|
||||||
async addSkillToEvaluation(
|
async addSkillToEvaluation(
|
||||||
profile: UserProfile,
|
profile: UserProfile,
|
||||||
|
|||||||
Reference in New Issue
Block a user