Module 5 - Sécurité et gestion des utilisateurs
Maîtrisez la sécurisation complète de votre instance WikiJS et la gestion avancée des utilisateurs
Module 5Introduction à la sécurité WikiJS
La sécurité est un aspect critique de toute application web. Dans ce module, nous explorons les mécanismes de protection de WikiJS, la gestion granulaire des utilisateurs et les bonnes pratiques pour sécuriser votre installation.
Principes de sécurité WikiJS
- Defense in Depth : Sécurité multicouche
- Principle of Least Privilege : Permissions minimales
- Zero Trust : Vérification constante
- Fail Secure : Sécurisé par défaut
- Audit Trail : Traçabilité complète
- Input Validation : Validation stricte
- Output Encoding : Échappement sécurisé
- Secure by Design : Sécurité intrinsèque
Menaces courantes
- Critique - Injection SQL
- Critique - XSS (Cross-Site Scripting)
- Moyen - CSRF
- Critique - Escalade de privilèges
- Moyen - Brute Force
- Faible - Information Disclosure
Gestion avancée des utilisateurs
WikiJS offre un système complet de gestion des utilisateurs avec profils, groupes, statuts et métadonnées étendues pour un contrôle granulaire.
Création et configuration des utilisateurs
# Configuration de base des utilisateurs
users:
# Paramètres de création
registration:
enabled: false # Désactiver l'auto-inscription
requireEmailVerification: true
allowDomains: ["monentreprise.com", "partenaire.com"]
blockDomains: ["tempmail.org", "guerrillamail.com"]
# Validation des mots de passe
passwordPolicy:
minLength: 12
requireUppercase: true
requireLowercase: true
requireNumbers: true
requireSymbols: true
preventCommonPasswords: true
preventReuse: 5 # Derniers mots de passe
maxAge: 90 # Jours avant expiration
# Paramètres de session
sessions:
maxConcurrent: 3 # Sessions simultanées max
timeout: 8 # Heures d'inactivité
rememberDuration: 30 # Jours "Se souvenir de moi"
# Restrictions compte
accountLockout:
enabled: true
maxAttempts: 5
lockoutDuration: 30 # minutes
resetAfter: 24 # heures
# Profil utilisateur requis
requiredFields:
- email
- displayName
- department
- manager
-- Table utilisateurs étendue
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
display_name VARCHAR(255) NOT NULL,
-- Informations de base
first_name VARCHAR(100),
last_name VARCHAR(100),
title VARCHAR(100),
department VARCHAR(100),
manager_id INTEGER REFERENCES users(id),
-- Contact
phone VARCHAR(20),
mobile VARCHAR(20),
office_location VARCHAR(100),
-- Sécurité
is_active BOOLEAN DEFAULT true,
is_verified BOOLEAN DEFAULT false,
must_change_password BOOLEAN DEFAULT false,
password_changed_at TIMESTAMP,
last_login_at TIMESTAMP,
last_login_ip INET,
failed_login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP NULL,
-- Métadonnées
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
-- RGPD
consent_date TIMESTAMP,
data_retention_until TIMESTAMP,
-- Authentification externe
provider_id VARCHAR(100),
provider_data JSONB DEFAULT '{}'::jsonb,
-- Préférences
locale VARCHAR(10) DEFAULT 'fr',
timezone VARCHAR(50) DEFAULT 'Europe/Paris',
theme VARCHAR(20) DEFAULT 'light',
-- Attributs personnalisés
custom_attributes JSONB DEFAULT '{}'::jsonb
);
-- Index pour les performances et sécurité
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_active ON users(is_active);
CREATE INDEX idx_users_department ON users(department);
CREATE INDEX idx_users_last_login ON users(last_login_at);
CREATE INDEX idx_users_provider ON users(provider_id);
CREATE INDEX idx_users_locked ON users(locked_until) WHERE locked_until IS NOT NULL;
Cycle de vie des utilisateurs
États du compte utilisateur
- Pending : Créé, en attente de vérification email
- Active : Compte actif et opérationnel
- Suspended : Suspendu temporairement
- Locked : Verrouillé (trop de tentatives)
- Disabled : Désactivé par admin
- Archived : Archivé (données conservées)
- Deleted : Supprimé (RGPD)
// Service de gestion du cycle de vie utilisateur
class UserLifecycleService {
constructor(db, auditLogger) {
this.db = db;
this.audit = auditLogger;
}
async createUser(userData, createdBy) {
const transaction = await this.db.transaction();
try {
// 1. Validation des données
this.validateUserData(userData);
// 2. Vérification des doublons
await this.checkDuplicates(userData.email);
// 3. Génération du mot de passe temporaire
const tempPassword = this.generateSecurePassword();
const hashedPassword = await bcrypt.hash(tempPassword, 12);
// 4. Création de l'utilisateur
const user = await this.db.users.create({
...userData,
password_hash: hashedPassword,
must_change_password: true,
created_by: createdBy,
status: 'pending'
}, { transaction });
// 5. Envoi de l'email d'activation
await this.sendActivationEmail(user, tempPassword);
// 6. Audit log
await this.audit.log({
action: 'USER_CREATED',
userId: createdBy,
targetUserId: user.id,
details: { email: user.email, department: user.department }
});
await transaction.commit();
return user;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async suspendUser(userId, reason, suspendedBy, duration = null) {
const user = await this.db.users.findByPk(userId);
if (!user) throw new Error('User not found');
const suspendUntil = duration ?
new Date(Date.now() + duration * 24 * 60 * 60 * 1000) : null;
await this.db.users.update(
{
status: 'suspended',
suspended_until: suspendUntil,
suspended_reason: reason
},
{ where: { id: userId } }
);
// Invalider toutes les sessions actives
await this.invalidateUserSessions(userId);
// Audit
await this.audit.log({
action: 'USER_SUSPENDED',
userId: suspendedBy,
targetUserId: userId,
details: { reason, duration }
});
// Notification
await this.notifyUserSuspension(user, reason, suspendUntil);
}
async archiveUser(userId, archivedBy, reason) {
const transaction = await this.db.transaction();
try {
// 1. Vérifier que l'utilisateur existe
const user = await this.db.users.findByPk(userId);
if (!user) throw new Error('User not found');
// 2. Créer une archive
await this.db.user_archives.create({
original_user_id: userId,
user_data: user.toJSON(),
archived_by: archivedBy,
reason: reason
}, { transaction });
// 3. Anonymiser les données sensibles
await this.db.users.update({
status: 'archived',
email: `archived_${userId}@internal.local`,
display_name: 'Utilisateur archivé',
first_name: null,
last_name: null,
phone: null,
is_active: false
}, { where: { id: userId }, transaction });
// 4. Audit
await this.audit.log({
action: 'USER_ARCHIVED',
userId: archivedBy,
targetUserId: userId,
details: { reason }
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
}
Système de groupes et hiérarchies
-- Table des groupes avec hiérarchie
CREATE TABLE groups (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
parent_group_id INTEGER REFERENCES groups(id),
-- Métadonnées
color VARCHAR(7) DEFAULT '#007bff',
icon VARCHAR(50) DEFAULT 'users',
sort_order INTEGER DEFAULT 0,
-- Paramètres
is_system BOOLEAN DEFAULT false,
auto_join BOOLEAN DEFAULT false,
requires_approval BOOLEAN DEFAULT true,
max_members INTEGER DEFAULT NULL,
-- Hiérarchie (Materialized Path)
path LTREE,
level INTEGER DEFAULT 0,
-- Audit
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Table d'appartenance avec rôles dans le groupe
CREATE TABLE user_groups (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
-- Rôle dans le groupe
role VARCHAR(50) DEFAULT 'member', -- member, admin, owner
-- Statut
status VARCHAR(20) DEFAULT 'active', -- active, pending, suspended
-- Métadonnées
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
invited_by INTEGER REFERENCES users(id),
expires_at TIMESTAMP NULL,
UNIQUE(user_id, group_id)
);
-- Index pour les requêtes hiérarchiques
CREATE INDEX idx_groups_path ON groups USING GIST (path);
CREATE INDEX idx_groups_parent ON groups(parent_group_id);
CREATE INDEX idx_user_groups_user ON user_groups(user_id);
CREATE INDEX idx_user_groups_group ON user_groups(group_id);
// Service de gestion des groupes
class GroupService {
constructor(db) {
this.db = db;
}
async createGroup(groupData, parentGroupId = null) {
const transaction = await this.db.transaction();
try {
let path = '';
let level = 0;
if (parentGroupId) {
const parent = await this.db.groups.findByPk(parentGroupId);
if (!parent) throw new Error('Parent group not found');
path = `${parent.path}.${parentGroupId}`;
level = parent.level + 1;
} else {
path = 'root';
}
const group = await this.db.groups.create({
...groupData,
parent_group_id: parentGroupId,
path: path,
level: level
}, { transaction });
await transaction.commit();
return group;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async getGroupHierarchy(groupId) {
// Récupérer tous les groupes enfants
const children = await this.db.groups.findAll({
where: {
path: { [Op.like]: `%.${groupId}.%` }
},
order: [['level', 'ASC'], ['name', 'ASC']]
});
// Récupérer tous les groupes parents
const group = await this.db.groups.findByPk(groupId);
const parentIds = group.path.split('.').filter(id => id !== 'root');
const parents = await this.db.groups.findAll({
where: { id: { [Op.in]: parentIds } }
});
return { group, parents, children };
}
async getUserEffectiveGroups(userId) {
// Groupes directs
const directGroups = await this.db.query(`
SELECT g.*, ug.role, ug.joined_at
FROM groups g
JOIN user_groups ug ON g.id = ug.group_id
WHERE ug.user_id = :userId AND ug.status = 'active'
`, {
replacements: { userId },
type: QueryTypes.SELECT
});
// Groupes hérités (parents des groupes directs)
const inheritedGroups = await this.db.query(`
WITH user_group_paths AS (
SELECT g.path
FROM groups g
JOIN user_groups ug ON g.id = ug.group_id
WHERE ug.user_id = :userId AND ug.status = 'active'
)
SELECT DISTINCT g.*
FROM groups g, user_group_paths ugp
WHERE g.path @> ugp.path::ltree AND g.path != ugp.path
`, {
replacements: { userId },
type: QueryTypes.SELECT
});
return { direct: directGroups, inherited: inheritedGroups };
}
async addUserToGroup(userId, groupId, role = 'member', invitedBy = null) {
// Vérifier la capacité du groupe
const group = await this.db.groups.findByPk(groupId);
if (group.max_members) {
const currentCount = await this.db.user_groups.count({
where: { group_id: groupId, status: 'active' }
});
if (currentCount >= group.max_members) {
throw new Error('Group is at maximum capacity');
}
}
// Vérifier si l'utilisateur n'est pas déjà membre
const existing = await this.db.user_groups.findOne({
where: { user_id: userId, group_id: groupId }
});
if (existing) {
if (existing.status === 'active') {
throw new Error('User is already a member');
} else {
// Réactiver l'appartenance
await existing.update({
status: 'active',
role: role,
invited_by: invitedBy
});
return existing;
}
}
// Créer la nouvelle appartenance
const status = group.requires_approval && !invitedBy ? 'pending' : 'active';
return await this.db.user_groups.create({
user_id: userId,
group_id: groupId,
role: role,
status: status,
invited_by: invitedBy
});
}
}
Profils et métadonnées étendues
Profils personnalisables
WikiJS permet d'étendre les profils utilisateur avec des champs personnalisés, des préférences et des métadonnées métier spécifiques à votre organisation.
# Configuration des profils utilisateur
profiles:
# Champs personnalisés
customFields:
- name: "employee_id"
type: "string"
required: true
validation: "^EMP[0-9]{6}$"
- name: "security_clearance"
type: "enum"
options: ["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]
default: "PUBLIC"
- name: "cost_center"
type: "string"
required: false
- name: "hire_date"
type: "date"
required: false
- name: "certifications"
type: "array"
itemType: "string"
- name: "skills"
type: "tags"
maxItems: 10
- name: "avatar_url"
type: "url"
validation: "^https://.*\\.(jpg|jpeg|png|gif)$"
# Préférences utilisateur
preferences:
notification_types:
- page_comment
- page_update
- group_invitation
- system_maintenance
notification_methods:
- email
- push
- in_app
ui_preferences:
- theme
- language
- timezone
- date_format
# Visibilité des champs
visibility:
public: ["display_name", "title", "department"]
internal: ["phone", "office_location", "manager"]
private: ["employee_id", "hire_date"]
Système de rôles et permissions granulaires
WikiJS implémente un système RBAC complet avec permissions granulaires, héritage de rôles et contrôle d'accès basé sur les attributs (ABAC).
# Configuration RBAC avancée
rbac:
# Définition des rôles
roles:
guest:
name: "Invité"
description: "Accès lecture seule aux pages publiques"
permissions:
- "read:pages:public"
- "read:assets:public"
weight: 0
user:
name: "Utilisateur"
description: "Utilisateur standard avec création de contenu"
inherits: ["guest"]
permissions:
- "read:pages:internal"
- "write:pages:own"
- "comment:pages"
- "upload:assets:own"
weight: 10
editor:
name: "Éditeur"
description: "Éditeur de contenu avec permissions étendues"
inherits: ["user"]
permissions:
- "write:pages:*"
- "delete:pages:own"
- "moderate:comments"
- "manage:assets:category"
weight: 50
admin:
name: "Administrateur"
description: "Administration utilisateurs et configuration"
inherits: ["editor"]
permissions:
- "manage:users"
- "manage:groups"
- "read:system:logs"
- "manage:pages:*"
weight: 90
superadmin:
name: "Super Administrateur"
description: "Accès complet au système"
permissions:
- "*"
weight: 100
# Permissions personnalisées
customPermissions:
- name: "approve:financial"
description: "Approuver le contenu financier"
category: "business"
- name: "export:data"
description: "Exporter les données"
category: "data"
- name: "access:hr"
description: "Accès aux ressources humaines"
category: "department"
# Règles contextuelles (ABAC)
contextualRules:
- name: "own_content_rule"
condition: "user.id === resource.author_id"
grants: ["write:pages", "delete:pages"]
- name: "department_rule"
condition: "user.department === resource.department"
grants: ["read:pages:departmental"]
- name: "manager_rule"
condition: "user.role === 'manager' && resource.author.manager_id === user.id"
grants: ["approve:content"]
// Middleware avancé de contrôle d'accès
class PermissionMiddleware {
constructor(rbacService, auditService) {
this.rbac = rbacService;
this.audit = auditService;
}
requirePermission(permission, options = {}) {
return async (req, res, next) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
// Vérifier la permission de base
const hasPermission = await this.rbac.userHasPermission(user.id, permission);
if (!hasPermission) {
// Vérifier les règles contextuelles si spécifiées
if (options.contextualRules) {
const resource = options.resourceLoader ?
await options.resourceLoader(req) : req.body;
const contextualAccess = await this.rbac.checkContextualAccess(
user, permission, resource, options.contextualRules
);
if (!contextualAccess) {
await this.audit.logAccessDenied({
userId: user.id,
permission,
resource: resource?.id,
ip: req.ip,
userAgent: req.get('User-Agent')
});
return res.status(403).json({ error: 'Permission denied' });
}
} else {
await this.audit.logAccessDenied({
userId: user.id,
permission,
ip: req.ip
});
return res.status(403).json({ error: 'Permission denied' });
}
}
// Log de l'accès autorisé
await this.audit.logAccess({
userId: user.id,
permission,
granted: true,
ip: req.ip
});
next();
} catch (error) {
console.error('Permission check failed:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
}
requireRole(roleName, options = {}) {
return async (req, res, next) => {
const user = req.user;
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const userRoles = await this.rbac.getUserRoles(user.id);
const hasRole = userRoles.some(role =>
role.name === roleName ||
(options.allowInherited && role.inheritsFrom?.includes(roleName))
);
if (!hasRole) {
await this.audit.logAccessDenied({
userId: user.id,
requiredRole: roleName,
userRoles: userRoles.map(r => r.name),
ip: req.ip
});
return res.status(403).json({ error: 'Insufficient role' });
}
next();
};
}
// Middleware pour permissions granulaires sur les ressources
requireResourceAccess(resourceType, action, options = {}) {
return async (req, res, next) => {
try {
const user = req.user;
const resourceId = req.params.id || req.body.id;
if (!resourceId) {
return res.status(400).json({ error: 'Resource ID required' });
}
// Charger la ressource
const resource = await this.rbac.getResource(resourceType, resourceId);
if (!resource) {
return res.status(404).json({ error: 'Resource not found' });
}
// Vérifier l'accès
const hasAccess = await this.rbac.checkResourceAccess(
user.id, resourceType, resourceId, action, options
);
if (!hasAccess) {
await this.audit.logResourceAccessDenied({
userId: user.id,
resourceType,
resourceId,
action,
ip: req.ip
});
return res.status(403).json({ error: 'Resource access denied' });
}
// Attacher la ressource à la requête
req.resource = resource;
next();
} catch (error) {
console.error('Resource access check failed:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
}
}
// Utilisation dans les routes
app.get('/api/pages/:id',
authMiddleware,
permissionMiddleware.requireResourceAccess('page', 'read'),
async (req, res) => {
// La page est déjà chargée dans req.resource
res.json(req.resource);
}
);
app.put('/api/users/:id',
authMiddleware,
permissionMiddleware.requirePermission('manage:users', {
contextualRules: ['own_user_rule', 'manager_rule']
}),
async (req, res) => {
// Logique de mise à jour utilisateur
}
);