First commit

This commit is contained in:
Johan
2025-12-18 15:28:26 +01:00
commit 954e3640ff
8 changed files with 4431 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/.idea
node_modules

186
README.md Normal file
View File

@@ -0,0 +1,186 @@
# TP gestionnaire de mots de passe sécurisé
## Objectifs
* Comprendre l'authentification moderne (JWT stocké en **Cookie HttpOnly** vs LocalStorage).
* Manipuler des primitives cryptographiques (AES-256-GCM, Argon2).
* Implémenter les opérations CRUD sécurisées où le serveur chiffre/déchiffre les données.
* **Analyse critique** : identifier les limites de sécurité d'une architecture "Server-Side Encryption".
## Structure du projet
Le projet est constitué de :
1. `server.js` : L'API Node.js (Express + SQLite). C'est ici que vous allez travailler.
2. `db_final.sqlite` : La base de données (générée automatiquement).
3. `vanilla_client/` : Le client front-end (fourni, ne pas modifier).
Le schéma général de l'application est le suivant :
```mermaid
flowchart LR
Client["Browser (client vanilla_client/)"] -- "Requêtes http (api/...)" --> Server("Serveur (server.js)")
Server -- "Cookie HttpOnly (JWT)" --> Client
Server -- "Requêtes sql (lecture/écriture)" --> DB[("Base de données (db_final.sqlite)")]
```
-----
## Comment lancer le projet
1. Ouvrez le dossier dans votre terminal.
2. Installez les dépendances :
```bash
npm install
```
3. Lancez le serveur :
```bash
nodemon server.js
```
4. Pour le client : clic droit sur `vanilla_client/index.html` \> "Run".
-----
## Étape 1 : analyse de l'existant (lecture de code)
Avant de coder, analysez le fichier `server.js`, spécifiquement les parties **Configuration**, **Middleware** et les routes `/register` et `/login`.
Répondez (pour vous-mêmes ou sur papier) aux questions suivantes pour comprendre le flux :
1. **Le secret du coffre :**
* Lors du `/register`, nous générons une `userVaultKey` aléatoire. C'est la clé qui chiffrera tous les mots de passe de l'utilisateur.
* Cette clé n'est **jamais** stockée en clair dans la BDD. Comment est-elle protégée avant d'être insérée dans la table `users` ?
<!-- end list -->
```mermaid
sequenceDiagram
participant Client
participant Serveur
participant BDD
Client->>Serveur: POST /register (login, password)
activate Serveur
Serveur->>Serveur: Hash password "ex: Argon2(password)"
Serveur->>Serveur: Génère userVaultKey "clé aléatoire"
Serveur->>Serveur: Chiffre vaultKey "AES(vaultKey, SERVER_MASTER_KEY)"
Serveur->>BDD: INSERT users (login, hash, encryptedVaultKey)
activate BDD
BDD-->>Serveur: Utilisateur créé
deactivate BDD
Serveur-->>Client: Réponse 201 "utilisateur créé"
deactivate Serveur
```
2. **Le JWT "Stateful" :**
* Lors du `/login`, le serveur récupère la `userVaultKey` de l'utilisateur.
* Où le serveur stocke-t-il cette clé pour que l'utilisateur puisse s'en servir lors des requêtes suivantes (ex: ajouter un mot de passe) ?
* Pourquoi chiffrons-nous cette clé avec `SERVER_MASTER_KEY` avant de la mettre dans le JWT ?
<!-- end list -->
```mermaid
sequenceDiagram
participant Client
participant Serveur
participant BDD
Client->>Serveur: "POST /login (login, password)"
activate Serveur
Serveur->>BDD: "SELECT * FROM users WHERE login = ?"
activate BDD
BDD-->>Serveur: "Données utilisateur (hash, encryptedVaultKey)"
deactivate BDD
Serveur->>Serveur: "Vérifie hash (Argon2(password) vs hash)"
alt "Mot de passe correct"
Serveur->>Serveur: "Déchiffre vaultKey (AES(encryptedVaultKey, MASTER_KEY))"
Serveur->>Serveur: "Crée JWT (payload: { userId, decryptedVaultKey })"
Serveur-->>Client: "Réponse 200 OK (Set-Cookie: token=JWT HttpOnly)"
else "Mot de passe incorrect"
Serveur-->>Client: "Réponse 401 (non autorisé)"
end
deactivate Serveur
```
3. **Sécurité du Token :**
* Regardez la méthode `res.cookie` dans `/login`. Pourquoi l'option `httpOnly: true` est-elle cruciale ici ? Le JavaScript côté client (ex: `script.js`) peut-il lire ce cookie ?
-----
## Étape 2 : implémentation (à vous de jouer)
Le serveur démarre, vous pouvez vous inscrire et vous connecter. Cependant, les fonctionnalités liées aux mots de passe renvoient des erreurs ou sont vides.
Vous devez compléter les **3 routes manquantes** dans `server.js` (cherchez les commentaires `TODO`).
### Mission A : sauvegarder une entrée (`POST /entries`)
**Objectif :** chiffrer le mot de passe reçu avant de l'insérer en base.
* **Aide :**
* Le middleware `authenticateAndUnpackKey` a déjà fait le travail difficile. Il a déchiffré la clé de l'utilisateur et l'a placée dans `req.user.vaultKey`.
* Utilisez la fonction utilitaire `encryptAES(texte, clé)` définie plus haut dans le fichier.
* Regardez la structure de la table `entries` pour savoir quoi insérer (notamment `encrypted_blob`, `iv`, `auth_tag`).
*Voici le flux d'une requête authentifiée que vous devez implémenter :*
```mermaid
sequenceDiagram
participant Client
participant Serveur
participant BDD
Client->>Serveur: POST /entries (payload: { title, ... }, cookie: token)
activate Serveur
Note over Serveur: Middleware "authenticateAndUnpackKey"
Serveur->>Serveur: Vérifie le JWT (reçu du cookie)
Serveur->>Serveur: Extrait payload "{ userId, decryptedVaultKey }"
Serveur->>Serveur: Stocke "decryptedVaultKey" dans "req.user.vaultKey"
Note over Serveur: Exécution de la route (Mission A)
Serveur->>Serveur: Chiffre le mot de passe "AES(data, req.user.vaultKey)"
Serveur->>BDD: INSERT entries (owner_id, encrypted_blob, iv, tag)
activate BDD
BDD-->>Serveur: Entrée sauvegardée
deactivate BDD
Serveur-->>Client: Réponse 201 "créé"
deactivate Serveur
```
### Mission B : déchiffrer un mot de passe (`GET /entries/:id/password`)
**Objectif :** Renvoyer le mot de passe en clair à l'utilisateur lorsqu'il clique sur l'icône "Oeil".
* **Aide :**
* Récupérez l'entrée en BDD via son `id`.
* **Sécurité :** Assurez-vous dans la clause `WHERE` que l'entrée appartient bien à l'utilisateur connecté (`owner_id`).
* Utilisez `decryptAES(...)` avec la `req.user.vaultKey` pour retrouver le texte clair.
### Mission C : lister les entrées (`GET /entries`)
**Objectif :** Renvoyer la liste des sites pour le tableau de bord. Aucun mot de passe ne doit être inclus.
* **Aide :**
* Faites un `SELECT` classique.
* **Performance & Sécurité :** Ne renvoyez **JAMAIS** le champ `encrypted_blob` (le mot de passe chiffré) ou les IV/Tags dans cette liste. On ne veut que les métadonnées (`id`, `title`, `url`, `username_field`) pour construire l'interface. Le déchiffrement se fera uniquement à la demande (Mission B).
-----
## Étape 3 : audit de sécurité (réflexion)
Une fois votre code fonctionnel, prenez du recul. Dans cette architecture, le chiffrement se fait **Côté Serveur**.
**Scénario catastrophe :**
Imaginez qu'un attaquant (ou un employé malveillant) obtienne un accès complet à la machine qui héberge le serveur (accès aux fichiers et à la mémoire vive).
1. L'attaquant trouve le fichier `.env` (ou le code) contenant la variable `SERVER_MASTER_KEY`.
2. L'attaquant fait un "Dump" de la base de données `db_final.sqlite`.
**Questions :**
* L'attaquant peut-il déchiffrer les mots de passe stockés dans la table `entries` ? Si oui, expliquez comment il procèderait étape par étape.
* **Architecture "Zero Knowledge"** : comment aurions-nous dû architecturer l'application pour que, même avec un accès total au serveur, l'attaquant ne puisse **jamais** lire les mots de passe des utilisateurs ? (Indice : Où devrait se faire le chiffrement AES ?)
-----

3452
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "secret-manager",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www"
},
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "~1.4.4",
"cors": "^2.8.5",
"debug": "~2.6.9",
"express": "~4.16.1",
"http-errors": "~1.6.3",
"jsonwebtoken": "^9.0.2",
"morgan": "~1.9.1",
"nodemon": "^3.1.11",
"pug": "2.0.0-beta11",
"sqlite3": "^5.1.7",
"uuid": "^13.0.0"
}
}

208
server.js Normal file
View File

@@ -0,0 +1,208 @@
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const argon2 = require('argon2');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cors({
// Note : si vous ouvrez le fichier HTML en double cliquant (file://), les cookies ne marcheront pas.
// Il faut utiliser "Live Server" WebStorm (clic droit sur le fichier "index.html" > "Run 'index.html'")
origin: 'http://localhost:63342', // L'adresse EXACTE de votre client (pas *)
credentials: true // Autorise l'envoi de cookies
}));
app.use(express.json());
app.use(cookieParser());
// --- CONFIGURATION ---
const JWT_SECRET = 'jwt_secret_pour_signature';
const SERVER_MASTER_KEY = crypto.randomBytes(32); // Clé interne serveur pour protéger le JWT
// Note : Dans la vraie vie, SERVER_MASTER_KEY doit être fixe et dans un .env ou var. env. (sinon au restart serveur, les JWT ne marchent plus)
const DB_SOURCE = "db_final.sqlite";
// --- OUTILS CRYPTO ---
// 1. Chiffrement Générique AES-GCM
const encryptAES = (text, key) => {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return { content: encrypted, iv: iv.toString('hex'), tag: cipher.getAuthTag().toString('hex') };
};
// 2. Déchiffrement Générique AES-GCM
const decryptAES = (encryptedHex, key, ivHex, tagHex) => {
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
};
// 3. Dérivation Argon2 (Password -> Key)
const deriveKeyFromPass = async (pass, salt) => {
return await argon2.hash(pass, {
type: argon2.argon2id, raw: true, salt: Buffer.from(salt, 'hex'), hashLength: 32
});
};
// --- BDD ---
const db = new sqlite3.Database(DB_SOURCE, (err) => {
if (err) throw err;
// Table Users : On stocke la CLÉ DU COFFRE (Vault Key) protégée par le mot de passe
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE,
auth_hash TEXT, -- Hash pour vérifier le login (rapide)
vault_key_salt TEXT, -- Sel pour dériver la clé
encrypted_vault_key TEXT, -- La clé AES de l'user, chiffrée par son pass
key_iv TEXT,
key_tag TEXT
)`);
// Table Entries : Le contenu est chiffré par la Vault Key
db.run(`CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY,
owner_id INTEGER,
title TEXT,
username_field TEXT,
url TEXT,
encrypted_blob TEXT,
iv TEXT,
auth_tag TEXT,
FOREIGN KEY(owner_id) REFERENCES users(id)
)`);
});
// --- MIDDLEWARE ---
// C'est lui qui récupère la clé de chiffrement des secrets dans le JWT
const authenticateAndUnpackKey = (req, res, next) => {
const token = req.cookies.auth_token;
if (!token) return res.sendStatus(401);
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) return res.sendStatus(403);
try {
// On récupère la clé de chiffrement de l'utilisateur stockée dans le token
// Elle est chiffrée avec la clé globale du serveur (ServerMasterKey), donc on la déchiffre.
const userVaultKeyHex = decryptAES(
decoded.encryptedKey,
SERVER_MASTER_KEY,
decoded.iv,
decoded.tag
);
// On attache la clé (Buffer) à la requête pour les routes suivantes
req.user = { ...decoded, vaultKey: Buffer.from(userVaultKeyHex, 'hex') };
next();
} catch (e) {
return res.status(403).json({error: "Token invalide ou corruption serveur"});
}
});
};
// --- ROUTES ---
// 1. Inscription
app.post('/register', async (req, res) => {
const { email, password } = req.body;
try {
// A. Login Auth Hash
const authHash = await argon2.hash(password);
// B. Création du "Trousseau" (Vault Key)
const userVaultKey = crypto.randomBytes(32); // La clé réelle qui chiffrera les données
const vaultSalt = crypto.randomBytes(16).toString('hex');
// C. On chiffre ce trousseau avec le mot de passe de l'user
const keyDerivedFromPass = await deriveKeyFromPass(password, vaultSalt);
const encryptedVault = encryptAES(userVaultKey.toString('hex'), keyDerivedFromPass);
db.run(`INSERT INTO users (email, auth_hash, vault_key_salt, encrypted_vault_key, key_iv, key_tag) VALUES (?,?,?,?,?,?)`,
[email, authHash, vaultSalt, encryptedVault.content, encryptedVault.iv, encryptedVault.tag],
function(err) {
if (err) return res.status(400).json({ error: "Erreur création" });
res.json({ message: "Compte créé avec succès" });
}
);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// 2. Login (C'est ici qu'on prépare le JWT "chargé")
app.post('/login', (req, res) => {
const { email, password } = req.body;
db.get("SELECT id, email, encrypted_vault_key, vault_key_salt, key_iv, key_tag, auth_hash FROM users WHERE email = ?", [email], async (err, user) => {
if (!user || !await argon2.verify(user.auth_hash, password)) {
return res.status(401).json({ error: "Creds invalides" });
}
try {
// A. On recrée la clé qui protège le trousseau
const keyDerivedFromPass = await deriveKeyFromPass(password, user.vault_key_salt);
// B. On déchiffre le trousseau (Vault Key)
// Si ça marche, c'est que le mot de passe est bon ET qu'on a accès aux données
const userVaultKeyHex = decryptAES(user.encrypted_vault_key, keyDerivedFromPass, user.key_iv, user.key_tag);
// C. On met ce trousseau DANS le JWT, mais chiffré par le SERVEUR
// (Pour que le client ne puisse pas le voir, et pour ne pas le stocker en session serveur)
const tokenPayloadEncrypted = encryptAES(userVaultKeyHex, SERVER_MASTER_KEY);
const token = jwt.sign({
id: user.id,
email: user.email,
// On injecte le payload chiffré
encryptedKey: tokenPayloadEncrypted.content,
iv: tokenPayloadEncrypted.iv,
tag: tokenPayloadEncrypted.tag
}, JWT_SECRET, { expiresIn: '1h' });
// res.json({ token }); // <-- Avant, on envoyait le token dans le corps de la réponse, maintenant on l'envoie en cookie sécurisé (protection XSS/CSRF), cf ci-dessous
res.cookie('auth_token', token, {
httpOnly: true, // JS ne peut pas le lire (Protection XSS, CSRF)
secure: false, // Mettre à 'true' si vous êtes en HTTPS
sameSite: 'lax', // Protection CSRF
maxAge: 3600000 // 1 heure (en ms)
});
res.json({ message: "Connexion réussie" }); // On n'envoie plus le token dans le corps !
} catch (e) {
res.status(500).json({ error: "Erreur crypto login" });
}
});
});
// 3. Créer une entrée
app.post('/entries', authenticateAndUnpackKey, (req, res) => {
const { title, username_field, url, password } = req.body;
// TODO
});
// 4. Déchiffrer une entrée
app.get('/entries/:id/password', authenticateAndUnpackKey, (req, res) => {
// TODO
});
// 5. Lister les entrées (Métadonnées uniquement)
app.get('/entries', authenticateAndUnpackKey, (req, res) => {
// TODO
});
// 6. Logout (supprime le cookie)
app.post('/logout', (req, res) => {
res.clearCookie('auth_token');
res.json({ message: "Déconnecté" });
});
app.listen(3000, () => console.log("Serveur Secure Token démarré"));

112
vanilla-client/index.html Normal file
View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecureVault • Gestionnaire</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="toast-container"></div>
<div id="auth-view" class="view active">
<div class="auth-card">
<div class="auth-header">
<div class="logo-icon">🔒</div>
<h1>SecureVault</h1>
<p>Vos secrets, chiffrés de bout en bout.</p>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('login')">Connexion</button>
<button class="tab-btn" onclick="switchTab('register')">Inscription</button>
</div>
<form id="login-form" class="auth-form">
<div class="input-group">
<label>Email</label>
<input type="email" id="login-email" required placeholder="exemple@mail.com">
</div>
<div class="input-group">
<label>Mot de passe</label>
<input type="password" id="login-password" required placeholder="Votre phrase secrète">
</div>
<button type="submit" class="btn-primary">Se connecter</button>
</form>
<form id="register-form" class="auth-form hidden">
<div class="input-group">
<label>Email</label>
<input type="email" id="register-email" required placeholder="exemple@mail.com">
</div>
<div class="input-group">
<label>Mot de passe maître</label>
<input type="password" id="register-password" required placeholder="Choisissez une phrase longue">
<small>Ce mot de passe chiffrera votre coffre. Ne l'oubliez pas.</small>
</div>
<button type="submit" class="btn-secondary">Créer un compte</button>
</form>
</div>
</div>
<div id="dashboard-view" class="view hidden">
<nav class="navbar">
<div class="nav-brand">
<span class="logo-icon-sm">🔒</span> SecureVault
</div>
<div class="nav-actions">
<span id="user-email-display">user@example.com</span>
<button onclick="logout()" class="btn-logout">Déconnexion</button>
</div>
</nav>
<main class="container">
<div class="dashboard-header">
<h2>Mon Coffre</h2>
<button onclick="openModal()" class="btn-primary">+ Nouveau mot de passe</button>
</div>
<div id="entries-grid" class="entries-grid">
<div class="loading-spinner">Chargement...</div>
</div>
</main>
</div>
<div id="modal-overlay" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h3>Ajouter une entrée</h3>
<button onclick="closeModal()" class="btn-close">&times;</button>
</div>
<form id="add-entry-form">
<div class="input-group">
<label>Titre (ex: Netflix)</label>
<input type="text" id="entry-title" required>
</div>
<div class="input-group">
<label>URL (Optionnel)</label>
<input type="url" id="entry-url" placeholder="https://...">
</div>
<div class="input-group">
<label>Identifiant / Email</label>
<input type="text" id="entry-username" required>
</div>
<div class="input-group">
<label>Mot de passe à sécuriser</label>
<input type="text" id="entry-password" required class="code-font">
</div>
<div class="modal-actions">
<button type="button" onclick="closeModal()" class="btn-text">Annuler</button>
<button type="submit" class="btn-primary">Chiffrer & Sauvegarder</button>
</div>
</form>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

320
vanilla-client/script.js Normal file
View File

@@ -0,0 +1,320 @@
const API_URL = 'http://localhost:3000';
// --- DOM ELEMENTS ---
const authView = document.getElementById('auth-view');
const dashboardView = document.getElementById('dashboard-view');
const loginForm = document.getElementById('login-form');
const registerForm = document.getElementById('register-form');
const entriesGrid = document.getElementById('entries-grid');
const modalOverlay = document.getElementById('modal-overlay');
const userEmailDisplay = document.getElementById('user-email-display');
// --- INITIALIZATION ---
async function init() {
// Hack pour vérifier si on est loggué ou non, afin d'éviter une erreur 401 sur /entries au démarrage.
const isLoggedIn = localStorage.getItem('is_logged_in');
if (!isLoggedIn) {
// Cas 1 : Première visite ou déconnecté proprement
showAuth();
} else {
// Cas 2 : On pense être connecté, on vérifie auprès du serveur
// Petit hack UX : on stocke l'email dans localStorage (non sensible)
// juste pour l'affichage, car on ne souhaite pas stocker le token JWT côté client.
const savedEmail = localStorage.getItem('user_email_display');
if(savedEmail) userEmailDisplay.innerText = savedEmail;
await loadEntries(true);
}
}
// --- VIEW MANAGEMENT ---
function showAuth() {
authView.classList.add('active');
authView.classList.remove('hidden');
dashboardView.classList.remove('active');
dashboardView.classList.add('hidden');
}
function showDashboard() {
authView.classList.remove('active');
authView.classList.add('hidden');
dashboardView.classList.add('active');
dashboardView.classList.remove('hidden');
}
function switchTab(tab) {
const loginBtn = document.querySelector('.tab-btn:nth-child(1)');
const registerBtn = document.querySelector('.tab-btn:nth-child(2)');
if (tab === 'login') {
loginForm.classList.remove('hidden');
registerForm.classList.add('hidden');
loginBtn.classList.add('active');
registerBtn.classList.remove('active');
} else {
loginForm.classList.add('hidden');
registerForm.classList.remove('hidden');
loginBtn.classList.remove('active');
registerBtn.classList.add('active');
}
}
// --- API CALLS ---
// LOGIN
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch(`${API_URL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include' // <--- INDISPENSABLE : Envoie/Reçoit les cookies
});
const data = await res.json();
if (res.ok) {
// On marque qu'on est loggué
localStorage.setItem('is_logged_in', 'true');
// On sauvegarde l'email juste pour l'UI (pas de sécurité ici)
localStorage.setItem('user_email_display', email);
userEmailDisplay.innerText = email;
showToast('Connexion réussie', 'success');
showDashboard();
loadEntries();
loginForm.reset();
} else {
showToast(data.error || 'Erreur de connexion', 'error');
}
} catch (error) {
showToast('Erreur serveur', 'error');
}
});
// REGISTER
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('register-email').value;
const password = document.getElementById('register-password').value;
try {
const res = await fetch(`${API_URL}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include'
});
if (res.ok) {
showToast('Compte créé ! Connectez-vous.', 'success');
switchTab('login');
registerForm.reset();
} else {
const data = await res.json();
showToast(data.error, 'error');
}
} catch (error) {
showToast('Erreur serveur', 'error');
}
});
// LOGOUT (Nouvelle version Serveur)
async function logout() {
try {
// On demande au serveur de supprimer le cookie httpOnly
await fetch(`${API_URL}/logout`, {
method: 'POST',
credentials: 'include'
});
} catch (e) {
console.error("Erreur réseau logout", e);
} finally {
// Quoi qu'il arrive (succès ou erreur), on nettoie le client
localStorage.removeItem('user_email_display');
localStorage.removeItem('is_logged_in');
showAuth();
showToast('Déconnecté', 'success');
}
}
// LOAD ENTRIES
async function loadEntries(isInit = false) {
if(!isInit) entriesGrid.innerHTML = '<div class="loading-spinner">Chargement de votre coffre...</div>';
try {
const res = await fetch(`${API_URL}/entries`, {
method: 'GET',
credentials: 'include' // Envoie le cookie d'auth automatiquement
});
if (res.status === 401 || res.status === 403) {
// Si non autorisé, on affiche l'écran de login
if (!isInit) logout();
else showAuth();
return;
}
if (isInit) showDashboard(); // Si init réussit, on bascule sur le dashboard
const entries = await res.json();
renderEntries(entries);
} catch (error) {
console.error(error);
if(!isInit) entriesGrid.innerHTML = '<p>Impossible de charger les données.</p>';
}
}
function renderEntries(entries) {
entriesGrid.innerHTML = '';
if (!entries || entries.length === 0) {
entriesGrid.innerHTML = '<p style="grid-column: 1/-1; text-align:center; color:#888;">Aucun mot de passe enregistré.</p>';
return;
}
entries.forEach(entry => {
const card = document.createElement('div');
card.className = 'entry-card';
card.innerHTML = `
<div class="card-header">
<div class="card-title">${entry.title}</div>
${entry.url ? `<a href="${entry.url}" target="_blank" class="card-url">Ouvrir ↗</a>` : ''}
</div>
<div class="card-body">
<div class="field-row">
<div class="field-label">Identifiant</div>
<div class="field-value-group">
<div class="field-value">${entry.username_field}</div>
<button class="btn-icon" onclick="copyToClipboard('${entry.username_field}')" title="Copier">📋</button>
</div>
</div>
<div class="field-row">
<div class="field-label">Mot de passe</div>
<div class="field-value-group">
<input type="password" class="field-value" value="********" readonly id="pwd-display-${entry.id}" style="border:none; background: #f9fafb;">
<button class="btn-icon" onclick="revealPassword('${entry.id}', this)" title="Voir / Cacher">👁️</button>
<button class="btn-icon" onclick="copyPasswordFromId('${entry.id}')" title="Copier">📋</button>
</div>
</div>
</div>
`;
entriesGrid.appendChild(card);
});
}
// REVEAL PASSWORD (DECRYPT)
async function revealPassword(id, btn) {
const input = document.getElementById(`pwd-display-${id}`);
if (input.type === 'text') {
input.type = 'password';
input.value = '********';
btn.innerText = '👁️';
return;
}
btn.innerText = '⌛';
try {
const res = await fetch(`${API_URL}/entries/${id}/password`, {
credentials: 'include' // Le cookie est nécessaire pour déchiffrer
});
if (res.ok) {
const data = await res.json();
input.type = 'text';
input.value = data.password;
btn.innerText = '🔒';
setTimeout(() => {
if(input.type === 'text') {
input.type = 'password';
input.value = '********';
btn.innerText = '👁️';
}
}, 10000);
} else {
showToast('Erreur déchiffrement', 'error');
btn.innerText = '👁️';
}
} catch (e) {
showToast('Erreur réseau', 'error');
btn.innerText = '👁️';
}
}
// CREATE ENTRY
document.getElementById('add-entry-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
title: document.getElementById('entry-title').value,
url: document.getElementById('entry-url').value,
username_field: document.getElementById('entry-username').value,
password: document.getElementById('entry-password').value
};
try {
const res = await fetch(`${API_URL}/entries`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include' // Important !
});
if (res.ok) {
showToast('Mot de passe sécurisé !', 'success');
closeModal();
document.getElementById('add-entry-form').reset();
loadEntries();
} else {
showToast('Erreur sauvegarde', 'error');
}
} catch (e) {
showToast('Erreur serveur', 'error');
}
});
// --- UTILS ---
function openModal() {
modalOverlay.classList.remove('hidden');
}
function closeModal() {
modalOverlay.classList.add('hidden');
}
function showToast(msg, type = 'success') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerText = msg;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => showToast('Copié !'));
}
async function copyPasswordFromId(id) {
const input = document.getElementById(`pwd-display-${id}`);
if (input.type === 'text' && input.value !== '********') {
copyToClipboard(input.value);
} else {
showToast("Affichez d'abord le mot de passe", 'error');
}
}
// Lancer l'app
init();

129
vanilla-client/style.css Normal file
View File

@@ -0,0 +1,129 @@
:root {
--primary: #4F46E5; /* Indigo */
--primary-hover: #4338ca;
--bg: #F3F4F6;
--surface: #FFFFFF;
--text: #1F2937;
--text-light: #6B7280;
--border: #E5E7EB;
--danger: #EF4444;
--success: #10B981;
--radius: 12px;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Inter', sans-serif; }
body { background-color: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; }
/* UTILITAIRES */
.hidden { display: none !important; }
.view { width: 100%; height: 100%; }
.view.active { display: flex; }
/* AUTH VIEW */
#auth-view { justify-content: center; align-items: center; }
.auth-card {
background: var(--surface);
padding: 2rem;
width: 100%;
max-width: 400px;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.auth-header { text-align: center; margin-bottom: 2rem; }
.logo-icon { font-size: 3rem; margin-bottom: 0.5rem; }
.auth-header h1 { font-size: 1.5rem; font-weight: 600; }
.auth-header p { color: var(--text-light); font-size: 0.9rem; }
.tabs { display: flex; border-bottom: 2px solid var(--border); margin-bottom: 1.5rem; }
.tab-btn {
flex: 1;
background: none;
border: none;
padding: 10px;
cursor: pointer;
font-weight: 600;
color: var(--text-light);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
/* FORMS */
.input-group { margin-bottom: 1rem; }
.input-group label { display: block; margin-bottom: 0.4rem; font-size: 0.9rem; font-weight: 500; color: var(--text); }
.input-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.input-group input:focus { outline: none; border-color: var(--primary); }
.input-group small { display: block; margin-top: 0.4rem; font-size: 0.8rem; color: var(--text-light); }
button { cursor: pointer; transition: all 0.2s; }
.btn-primary { width: 100%; padding: 0.8rem; background: var(--primary); color: white; border: none; border-radius: 8px; font-weight: 600; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-secondary { width: 100%; padding: 0.8rem; background: var(--text); color: white; border: none; border-radius: 8px; font-weight: 600; }
/* DASHBOARD */
#dashboard-view { flex-direction: column; }
.navbar {
background: var(--surface);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.nav-brand { font-weight: 700; font-size: 1.2rem; display: flex; align-items: center; gap: 10px;}
.nav-actions { display: flex; gap: 15px; align-items: center; font-size: 0.9rem; }
.btn-logout { background: transparent; border: 1px solid var(--border); padding: 0.4rem 1rem; border-radius: 6px; color: var(--text-light); }
.btn-logout:hover { border-color: var(--danger); color: var(--danger); }
.container { max-width: 1000px; margin: 2rem auto; padding: 0 1rem; width: 100%; }
.dashboard-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
.dashboard-header .btn-primary { width: auto; padding: 0.6rem 1.2rem; }
/* GRID ENTRIES */
.entries-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem; }
.entry-card {
background: var(--surface);
padding: 1.5rem;
border-radius: var(--radius);
border: 1px solid var(--border);
transition: transform 0.2s, box-shadow 0.2s;
}
.entry-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
.card-title { font-weight: 600; font-size: 1.1rem; }
.card-url { font-size: 0.8rem; color: var(--primary); text-decoration: none; }
.card-body { margin-top: 1rem; }
.field-row { margin-bottom: 0.8rem; }
.field-label { font-size: 0.75rem; color: var(--text-light); text-transform: uppercase; letter-spacing: 0.5px; }
.field-value-group { display: flex; gap: 8px; align-items: center; }
.field-value { font-family: 'Courier New', monospace; background: #f9fafb; padding: 4px 8px; border-radius: 4px; border: 1px solid #e5e7eb; width: 100%; overflow: hidden; text-overflow: ellipsis; }
.btn-icon { background: none; border: none; color: var(--text-light); padding: 4px; border-radius: 4px; }
.btn-icon:hover { background: #e5e7eb; color: var(--primary); }
/* MODAL */
.modal-overlay { position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 1000; }
.modal { background: var(--surface); padding: 2rem; border-radius: var(--radius); width: 100%; max-width: 500px; animation: slideUp 0.3s ease; }
.modal-header { display: flex; justify-content: space-between; margin-bottom: 1.5rem; }
.btn-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; }
.modal-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1.5rem; }
.btn-text { background: none; border: none; color: var(--text-light); }
/* TOAST */
#toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 2000; }
.toast { background: #333; color: white; padding: 12px 24px; border-radius: 8px; margin-top: 10px; animation: fadeIn 0.3s; font-size: 0.9rem; }
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }