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 5

Introduction à 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.

7
Niveaux de sécurité
15+
Types de permissions
OWASP
Standards respectés
RGPD
Conformité incluse
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

User Management System

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
Cycle de vie
Groupes
Profils
Création et configuration des utilisateurs
Configuration 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
Modèle de données utilisateur étendu
-- 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)
Workflow de gestion utilisateur
// 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
Structure hiérarchique des groupes
-- 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);
Gestion des groupes hiérarchiques
// 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 étendus
# 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

RBAC - Role-Based Access Control

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).

Matrice des permissions par rôle
Permission Guest User Editor Admin Super Admin
read:pages
write:pages
delete:pages
manage:users
manage:system
Configuration du système de permissions
# 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 de vérification des permissions
// 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
  }
);