ShibService.php 19.3 KB
Newer Older
1
2
3
4
<?php

namespace UnicaenAuth\Service;

5
6
use Assert\Assertion;
use Assert\AssertionFailedException;
7
use InvalidArgumentException;
8
use UnicaenApp\Exception\LogicException;
9
use UnicaenApp\Exception\RuntimeException;
10
use UnicaenAuth\Entity\Db\AbstractUser;
11
use UnicaenAuth\Entity\Shibboleth\ShibUser;
12
use Zend\Router\Http\TreeRouteStack;
13
use Zend\Session\Container;
14
15

/**
16
 * Shibboleth service.
17
18
19
20
21
 *
 * @author Unicaen
 */
class ShibService
{
22
23
24
25
    const SHIB_USER_ID_EXTRACTOR = 'shib_user_id_extractor';

    const DOMAIN_DEFAULT = 'default';

26
27
28
    const KEY_fromShibUser = 'fromShibUser';
    const KEY_toShibUser = 'toShibUser';

29
    /**
30
     * @var ShibUser
31
     */
32
    protected $authenticatedUser;
33
34

    /**
35
     * @var array
36
     */
37
38
39
40
41
42
    protected $shibbolethConfig = [];

    /**
     * @var array
     */
    protected $usurpationAllowedUsernames = [];
43
44
45
46

    /**
     * @return string
     */
47
    static public function apacheConfigSnippet(): string
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
    {
        $text = <<<EOS
<Location "/">
    AuthType Shibboleth
    ShibRequestSetting requireSession false
    Require shibboleth
</Location>
<Location "/auth/shibboleth">
        AuthType Shibboleth
        ShibRequestSetting requireSession true
        Require shibboleth
</Location>
EOS;
        return $text;
    }

64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
    /**
     * @param array $shibbolethConfig
     */
    public function setShibbolethConfig(array $shibbolethConfig)
    {
        $this->shibbolethConfig = $shibbolethConfig;
    }

    /**
     * @param array $usurpationAllowedUsernames
     */
    public function setUsurpationAllowedUsernames(array $usurpationAllowedUsernames)
    {
        $this->usurpationAllowedUsernames = $usurpationAllowedUsernames;
    }

80
81
82
    /**
     * @return ShibUser|null
     */
83
    public function getAuthenticatedUser(): ?ShibUser
84
85
86
87
88
89
    {
        if (! $this->isShibbolethEnabled()) {
            return null;
        }

        if ($this->authenticatedUser === null) {
90

91
            // D'ABORD activation éventuelle de la simulation
92
            $this->handleSimulation();
93
94
            // ENSUITE activation éventuelle de l'usurpation
            $this->handleUsurpation();
95

96
97
98
99
            if (! $this->isAuthenticated()) {
                return null;
            }

100
101
102
103
104
105
            $this->authenticatedUser = $this->createShibUserFromServerArrayData();
        }

        return $this->authenticatedUser;
    }

106
107
108
    /**
     * @return bool
     */
109
    private function isAuthenticated(): bool
110
    {
111
        return
112
113
114
            $this->getValueFromShibData('REMOTE_USER', $_SERVER) ||
            $this->getValueFromShibData('Shib-Session-ID', $_SERVER) ||
            $this->getValueFromShibData('HTTP_SHIB_SESSION_ID', $_SERVER);
115
116
    }

117
118
119
    /**
     * @return boolean
     */
120
    public function isShibbolethEnabled(): bool
121
    {
122
123
124
        return
            array_key_exists('enabled', $this->shibbolethConfig) && (bool) $this->shibbolethConfig['enabled'] ||
            array_key_exists('enable', $this->shibbolethConfig) && (bool) $this->shibbolethConfig['enable'];
125
126
    }

127
128
129
    /**
     * @return array
     */
130
    public function getShibbolethSimulate(): array
131
    {
132
        if (! array_key_exists('simulate', $this->shibbolethConfig) || ! is_array($this->shibbolethConfig['simulate'])) {
133
134
135
            return [];
        }

136
        return $this->shibbolethConfig['simulate'];
137
138
    }

139
140
141
142
    /**
     * @param string $attributeName
     * @return string
     */
143
    private function getShibbolethAliasFor(string $attributeName): ?string
144
    {
145
146
147
        if (! array_key_exists('aliases', $this->shibbolethConfig) ||
            ! is_array($this->shibbolethConfig['aliases']) ||
            ! isset($this->shibbolethConfig['aliases'][$attributeName])) {
148
149
150
            return null;
        }

151
152
153
154
155
156
157
158
159
160
        return $this->shibbolethConfig['aliases'][$attributeName];
    }

    /**
     * Retourne les alias des attributs spécifiés.
     * Si un attribut n'a pas d'alias, c'est l'attribut lui-même qui est retourné.
     *
     * @param array $attributeNames
     * @return array
     */
161
    private function getAliasedShibbolethAttributes(array $attributeNames): array
162
163
164
165
166
167
168
169
    {
        $aliasedAttributes = [];
        foreach ($attributeNames as $attributeName) {
            $alias = $this->getShibbolethAliasFor($attributeName);
            $aliasedAttributes[$attributeName] = $alias ?: $attributeName;
        }

        return $aliasedAttributes;
170
171
    }

172
173
174
175
176
    /**
     * Retourne true si la simulation d'un utilisateur authentifié via Shibboleth est en cours.
     *
     * @return bool
     */
177
    public function isSimulationActive(): bool
178
    {
179
180
181
        if (array_key_exists('simulate', $this->shibbolethConfig) &&
            is_array($this->shibbolethConfig['simulate']) &&
            ! empty($this->shibbolethConfig['simulate'])) {
182
183
184
185
186
187
            return true;
        }

        return false;
    }

188
189
190
191
192
    /**
     * Retourne la liste des attributs requis.
     *
     * @return array
     */
193
    private function getShibbolethRequiredAttributes(): array
194
195
196
197
198
199
200
201
    {
        if (! array_key_exists('required_attributes', $this->shibbolethConfig)) {
            return [];
        }

        return (array)$this->shibbolethConfig['required_attributes'];
    }

202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
    /**
     * @return array
     */
    private function getShibUserIdExtractorDefaultConfig(): array
    {
        return $this->getShibUserIdExtractorConfigForDomain(self::DOMAIN_DEFAULT);
    }

    /**
     * Retourne la config permettant d'extraire l'id à partir des attributs.
     *
     * @param string $domain
     * @return array
     */
    private function getShibUserIdExtractorConfigForDomain(string $domain): array
    {
        $key = self::SHIB_USER_ID_EXTRACTOR;
        if (! array_key_exists($key, $this->shibbolethConfig)) {
            throw new RuntimeException("Aucune config '$key' trouvée.");
        }

        $config = $this->shibbolethConfig[$key];

        if (! array_key_exists($domain, $config)) {
            return [];
        }

        return $config[$domain];
    }

232
    /**
233
     *
234
     */
235
    public function handleSimulation()
236
    {
237
        if (! $this->isSimulationActive()) {
238
            return;
239
240
        }

241
        try {
242
            $shibUser = $this->createShibUserFromSimulationData();
243
244
245
246
        } catch (AssertionFailedException $e) {
            throw new LogicException("Configuration erronée", null, $e);
        }

247
248
249
250
251
252
253
        $this->simulateAuthenticatedUser($shibUser);
    }

    /**
     * @return ShibUser
     * @throws AssertionFailedException
     */
254
    private function createShibUserFromSimulationData(): ShibUser
255
256
257
258
259
260
261
262
263
264
265
266
267
    {
        $data = $this->getShibbolethSimulate();

        $this->assertRequiredAttributesExistInData($data);

        $eppn = $this->getValueFromShibData('eppn', $data);
        $email = $this->getValueFromShibData('mail', $data);
        $displayName = $this->getValueFromShibData('displayName', $data);
        $givenName = $this->getValueFromShibData('givenName', $data);
        $surname = $this->getValueFromShibData('sn', $data);
        $civilite = $this->getValueFromShibData('supannCivilite', $data);

        Assertion::contains($eppn, '@', "L'eppn '" . $eppn . "' n'est pas de la forme 'id@domaine' attendue (ex: 'tartempion@unicaen.fr')");
268
269

        $shibUser = new ShibUser();
270
        // propriétés de UserInterface
271
        $shibUser->setEppn($eppn);
272
273
274
275
        $shibUser->setUsername($eppn);
        $domain = $shibUser->getEppnDomain(); // possible uniquement après $shibUser->setEppn($eppn)
        $id = $this->extractShibUserIdValueForDomainFromShibData($domain, $data);
        $shibUser->setId($id);
276
        $shibUser->setDisplayName($displayName);
277
        $shibUser->setEmail($email);
278
        // autres propriétés
279
280
281
        $shibUser->setNom($surname);
        $shibUser->setPrenom($givenName);
        $shibUser->setCivilite($civilite);
282
283
284

        return $shibUser;
    }
285

286
287
288
289
290
    /**
     * Retourne true si les données stockées en session indiquent qu'une usurpation d'identité Shibboleth est en cours.
     *
     * @return bool
     */
291
    private function isUsurpationActive(): bool
292
    {
293
        return $this->getSessionContainer()->offsetExists(self::KEY_fromShibUser);
294
295
296
297
298
299
300
    }

    /**
     * @param ShibUser $fromShibUser
     * @param ShibUser $toShibUser
     * @return self
     */
301
    public function activateUsurpationOld(ShibUser $fromShibUser, ShibUser $toShibUser): self
302
    {
303
304
305
306
//        // le login doit faire partie des usurpateurs autorisés
//        if (! in_array($fromShibUser->getUsername(), $this->usurpationAllowedUsernames)) {
//            throw new RuntimeException("Usurpation non autorisée");
//        }
307
308

        $session = $this->getSessionContainer();
309
310
311
312
313
314
315
316
317
318
319
320
321
        $session->offsetSet(self::KEY_fromShibUser, $fromShibUser);
        $session->offsetSet(self::KEY_toShibUser, $toShibUser);

        return $this;
    }

    /**
     * @param ShibUser $currentShibUser
     * @param AbstractUser $utilisateurUsurpe
     * @return self
     */
    public function activateUsurpation(ShibUser $currentShibUser, AbstractUser $utilisateurUsurpe): self
    {
322
323
324
325
326
        if (! ShibUser::isEppn($utilisateurUsurpe->getUsername())) {
            // cas d'usurpation d'un compte local (db) depuis une authentification shib
            return $this;
        }

327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
        $toShibUser = new ShibUser();
        $toShibUser->setEppn($utilisateurUsurpe->getUsername());
        $toShibUser->setId(uniqid()); // peut pas mieux faire pour l'instant
        $toShibUser->setDisplayName($utilisateurUsurpe->getDisplayName());
        $toShibUser->setEmail($utilisateurUsurpe->getEmail());
        $toShibUser->setNom('?');     // peut pas mieux faire pour l'instant
        $toShibUser->setPrenom('?');  // peut pas mieux faire pour l'instant

//        // le login doit faire partie des usurpateurs autorisés
//        if (! in_array($fromShibUser->getUsername(), $this->usurpationAllowedUsernames)) {
//            throw new RuntimeException("Usurpation non autorisée");
//        }

        $session = $this->getSessionContainer();
        $session->offsetSet(self::KEY_fromShibUser, $currentShibUser);
        $session->offsetSet(self::KEY_toShibUser, $toShibUser);
343
344
345
346
347
348
349
350
351

        return $this;
    }

    /**
     * Suppression des données stockées en session concernant l'usurpation d'identité Shibboleth.
     *
     * @return self
     */
352
    public function deactivateUsurpation(): self
353
354
    {
        $session = $this->getSessionContainer();
355
356
        $session->offsetUnset(self::KEY_fromShibUser);
        $session->offsetUnset(self::KEY_toShibUser);
357
358
359
360
361

        return $this;
    }

    /**
362
     * @return self
363
     */
364
    public function handleUsurpation(): self
365
366
367
368
369
370
371
372
    {
        if (! $this->isUsurpationActive()) {
            return $this;
        }

        $session = $this->getSessionContainer();

        /** @var ShibUser|null $toShibUser */
373
        $toShibUser = $session->offsetGet($key = self::KEY_toShibUser);
374
        if ($toShibUser === null) {
375
            throw new RuntimeException("Anomalie: clé '$key' introuvable");
376
377
        }

378
        $this->simulateAuthenticatedUser($toShibUser);
379
380
381
382
383
384
385

        return $this;
    }

    /**
     * @return Container
     */
386
    private function getSessionContainer(): Container
387
388
389
390
    {
        return new Container(ShibService::class);
    }

391
392
393
394
    /**
     * @param array $data
     * @return array
     */
395
    private function getMissingRequiredAttributesFromData(array $data): array
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
    {
        $requiredAttributes = $this->getShibbolethRequiredAttributes();
        $missingAttributes = [];

        foreach ($requiredAttributes as $requiredAttribute) {
            // un pipe permet d'exprimer un OU logique, ex: 'supannEmpId|supannEtuId'
            $attributes = array_map('trim', explode('|', $requiredAttribute));
            // attributs aliasés
            $attributes = $this->getAliasedShibbolethAttributes($attributes);

            $found = false;
            foreach (array_map('trim', $attributes) as $attribute) {
                if (isset($data[$attribute])) {
                    $found = true;
                }
            }
            if (!$found) {
                // attributs aliasés, dont l'un au moins est manquant, mise sous forme 'a|b'
                $missingAttributes[] = implode('|', $attributes);
            }
        }

        return $missingAttributes;
    }

    /**
     * @param array $data
     * @throws InvalidArgumentException
     */
    private function assertRequiredAttributesExistInData(array $data)
    {
        $missingAttributes = $this->getMissingRequiredAttributesFromData($data);

        if (!empty($missingAttributes)) {
            throw new InvalidArgumentException(
                "Les attributs suivants sont manquants : " . implode(', ', $missingAttributes));
        }
    }

435
436
    /**
     * Inscrit dans le tableau $_SERVER le nécessaire pour usurper l'identité d'un utilisateur
437
     * qui se serait authentifié via Shibboleth.
438
439
440
     *
     * @param ShibUser $shibUser Utilisateur dont on veut usurper l'identité.
     */
441
    public function simulateAuthenticatedUser(ShibUser $shibUser)
442
    {
443
        // 'REMOTE_USER' (notamment) est utilisé pour savoir si un utilisateur est authentifié ou non
444
445
        $this->setServerArrayVariable('REMOTE_USER', $shibUser->getEppn());

446
        // pour certains attributs, on veut une valeur sensée!
447
        $this->setServerArrayVariable('eppn', $shibUser->getEppn());
448
        $this->setServerArrayVariable('supannEmpId', $shibUser->getId()); // ou bien 'supannEtuId', peu importe.
449
        $this->setServerArrayVariable('displayName', $shibUser->getDisplayName());
450
        $this->setServerArrayVariable('mail', $shibUser->getEppn());
451
452
        $this->setServerArrayVariable('sn', $shibUser->getNom());
        $this->setServerArrayVariable('givenName', $shibUser->getPrenom());
453
        $this->setServerArrayVariable('supannCivilite', $shibUser->getCivilite());
454
455
456
457
458
    }

    /**
     * @return ShibUser
     */
459
    private function createShibUserFromServerArrayData(): ShibUser
460
    {
461
462
463
464
        try {
            $this->assertRequiredAttributesExistInData($_SERVER);
        } catch (InvalidArgumentException $e) {
            throw new RuntimeException('Des attributs Shibboleth obligatoires font défaut dans $_SERVER.', null, $e);
465
466
        }

467
468
469
470
471
472
        $eppn = $this->getValueFromShibData('eppn', $_SERVER);
        $mail = $this->getValueFromShibData('mail', $_SERVER);
        $displayName = $this->getValueFromShibData('displayName', $_SERVER);
        $surname = $this->getValueFromShibData('sn', $_SERVER) ?: $this->getValueFromShibData('surname', $_SERVER);
        $givenName = $this->getValueFromShibData('givenName', $_SERVER);
        $civilite = $this->getValueFromShibData('supannCivilite', $_SERVER);
473

474
        $shibUser = new ShibUser();
475
        // propriétés de UserInterface
476
        $shibUser->setEppn($eppn);
477
        $shibUser->setUsername($eppn);
478
479
480
        $domain = $shibUser->getEppnDomain(); // possible uniquement après $shibUser->setEppn($eppn)
        $id = $this->extractShibUserIdValueForDomainFromShibData($domain, $_SERVER);
        $shibUser->setId($id);
481
482
        $shibUser->setDisplayName($displayName);
        $shibUser->setEmail($mail);
483
484
485
486
487
        $shibUser->setPassword(null);
        // autres propriétés
        $shibUser->setNom($surname);
        $shibUser->setPrenom($givenName);
        $shibUser->setCivilite($civilite);
488
489
490
491

        return $shibUser;
    }

492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
    /**
     * @param string $domain
     * @param array $data
     * @return string|null
     */
    private function extractShibUserIdValueForDomainFromShibData(string $domain, array $data): ?string
    {
        $config = $this->getShibUserIdExtractorConfigForDomain($domain);
        if (empty($config)) {
            $config = $this->getShibUserIdExtractorDefaultConfig();
            if (empty($config)) {
                throw new RuntimeException("Aucune config trouvée ni pour le domaine '$domain' ni par défaut.");
            }
        }

        foreach ($config as $array) {
            $name = $array['name'];
509
            $value = $this->getValueFromShibData($name, $data) ?: null;
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
            if ($value !== null) {
                $pregMatchPattern = $array['preg_match_pattern'] ?? null;
                if ($pregMatchPattern !== null) {
                    if (preg_match($pregMatchPattern, $value, $matches) === 0) {
                        throw new RuntimeException("Le pattern '$pregMatchPattern' n'a pas permis d'extraire une valeur de '$name'.");
                    }
                    $value = $matches[1];
                }

                return $value;
            }
        }

        return null;
    }

Bertrand Gauthier's avatar
Bertrand Gauthier committed
526
527
528
529
530
531
    /**
     * Retourne l'URL de déconnexion Shibboleth.
     *
     * @param string $returnAbsoluteUrl Eventuelle URL *absolue* de retour après déconnexion
     * @return string
     */
532
    public function getLogoutUrl($returnAbsoluteUrl = null): string
Bertrand Gauthier's avatar
Bertrand Gauthier committed
533
    {
534
535
536
        if ($this->getShibbolethSimulate()) {
            return '/';
        }
537
538
539
        if ($this->getAuthenticatedUser() === null) {
            return '/';
        }
540

541
        $logoutUrl = $this->shibbolethConfig['logout_url'];
Bertrand Gauthier's avatar
Bertrand Gauthier committed
542
543

        if ($returnAbsoluteUrl) {
544
            $logoutUrl .= urlencode($returnAbsoluteUrl);
Bertrand Gauthier's avatar
Bertrand Gauthier committed
545
546
        }

547
        return $logoutUrl;
Bertrand Gauthier's avatar
Bertrand Gauthier committed
548
549
550
551
552
553
554
    }

    /**
     * @param TreeRouteStack $router
     */
    public function reconfigureRoutesForShibAuth(TreeRouteStack $router)
    {
555
556
557
558
        if (! $this->isShibbolethEnabled()) {
            return;
        }

Bertrand Gauthier's avatar
Bertrand Gauthier committed
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
        $router->addRoutes([
            // remplace les routes existantes (cf. config du module)
            'zfcuser' => [
                'type'          => 'Literal',
                'priority'      => 1000,
                'options'       => [
                    'route'    => '/auth',
                    'defaults' => [
                        'controller' => 'zfcuser',
                        'action'     => 'index',
                    ],
                ],
                'may_terminate' => true,
                'child_routes'  => [
                    'login' => [
574
                        'type' => 'Segment',
Bertrand Gauthier's avatar
Bertrand Gauthier committed
575
                        'options' => [
576
                            'route' => '/connexion[/:type]',
Bertrand Gauthier's avatar
Bertrand Gauthier committed
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
                            'defaults' => [
                                'controller' => 'zfcuser', // NB: lorsque l'auth Shibboleth est activée, la page propose
                                'action'     => 'login',   //     2 possibilités d'auth : LDAP et Shibboleth.
                            ],
                        ],
                    ],
                    'logout' => [
                        'type'    => 'Segment',
                        'options' => [
                            'route'    => '/:operation/shibboleth/',
                            'defaults' => [
                                'controller' => 'UnicaenAuth\Controller\Auth',
                                'action'     => 'shibboleth',
                                'operation'  => 'deconnexion'
                            ],
                        ],
                    ],
                ],
            ],
        ]);
    }
598

599
600
    /**
     * @param string $name
601
     * @param array $data
602
603
     * @return string
     */
604
    private function getValueFromShibData(string $name, array $data): ?string
605
606
607
608
609
610
611
612
613
614
    {
        $key = $this->getShibbolethAliasFor($name) ?: $name;

        if (! array_key_exists($key, $data)) {
            return null;
        }

        return $data[$key];
    }

615
616
    /**
     * @param string $name
617
     * @param string|null $value
618
     */
619
    private function setServerArrayVariable(string $name, string $value = null)
620
621
622
623
624
    {
        $key = $this->getShibbolethAliasFor($name) ?: $name;

        $_SERVER[$key] = $value;
    }
625
}