Cas10Controller   A
last analyzed

Complexity

Total Complexity 17

Size/Duplication

Total Lines 151
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 17
eloc 64
dl 0
loc 151
rs 10
c 1
b 0
f 0

3 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 20 3
C validate() 0 81 13
A getTicketStore() 0 3 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\casserver\Controller;
6
7
use SimpleSAML\Configuration;
8
use SimpleSAML\Logger;
9
use SimpleSAML\Module;
10
use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory;
11
use SimpleSAML\Module\casserver\Cas\Protocol\Cas10;
12
use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore;
13
use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait;
14
use Symfony\Component\HttpFoundation\Request;
15
use Symfony\Component\HttpFoundation\Response;
16
use Symfony\Component\HttpKernel\Attribute\AsController;
17
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
18
19
#[AsController]
20
class Cas10Controller
21
{
22
    use UrlTrait;
23
24
    /** @var Logger */
25
    protected Logger $logger;
26
27
    /** @var Configuration */
28
    protected Configuration $casConfig;
29
30
    /** @var Cas10 */
31
    protected Cas10 $cas10Protocol;
32
33
    /** @var TicketFactory */
34
    protected TicketFactory $ticketFactory;
35
36
    /** @var TicketStore */
37
    protected TicketStore $ticketStore;
38
39
    /**
40
     * @param   Configuration       $sspConfig
41
     * @param   Configuration|null  $casConfig
42
     * @param   TicketStore|null    $ticketStore
43
     *
44
     * @throws \Exception
45
     */
46
    public function __construct(
47
        private readonly Configuration $sspConfig,
48
        Configuration $casConfig = null,
49
        TicketStore $ticketStore = null,
50
    ) {
51
        // We are using this work around in order to bypass Symfony's autowiring for cas configuration. Since
52
        // the configuration class is the same, it loads the ssp configuration twice. Still, we need the constructor
53
        // argument in order to facilitate testin.
54
        $this->casConfig = ($casConfig === null || $casConfig === $sspConfig)
55
            ? Configuration::getConfig('module_casserver.php') : $casConfig;
56
        $this->cas10Protocol = new Cas10($this->casConfig);
57
        /* Instantiate ticket factory */
58
        $this->ticketFactory = new TicketFactory($this->casConfig);
59
        /* Instantiate ticket store */
60
        $ticketStoreConfig = $this->casConfig->getOptionalValue(
61
            'ticketstore',
62
            ['class' => 'casserver:FileSystemTicketStore'],
63
        );
64
        $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
65
        $this->ticketStore = $ticketStore ?? new $ticketStoreClass($this->casConfig);
66
    }
67
68
    /**
69
     * @param   Request      $request
70
     * @param   bool  $renew  [OPTIONAL] - if this parameter is set, ticket validation will only succeed
71
     *                        if the service ticket was issued from the presentation of the user’s primary credentials.
72
     *                        It will fail if the ticket was issued from a single sign-on session.
73
     * @param   string|null  $ticket  [REQUIRED] - the service ticket issued by /login.
74
     * @param   string|null  $service [REQUIRED] - the identifier of the service for which the ticket was issued
75
     *
76
     * @return Response
77
     */
78
    public function validate(
79
        Request $request,
80
        #[MapQueryParameter] ?string $ticket = null,
81
        #[MapQueryParameter] bool $renew = false,
82
        #[MapQueryParameter] ?string $service = null,
83
    ): Response {
84
        $forceAuthn = $renew;
85
        // Check if any of the required query parameters are missing
86
        // Even though we can delegate the check to Symfony's `MapQueryParameter` we cannot return
87
        // the failure response needed. As a result, we allow a default value, and we handle the missing
88
        // values afterward.
89
        if ($service === null || $ticket === null) {
90
            $messagePostfix = $service === null ? 'service' : 'ticket';
91
            Logger::debug("casserver: Missing service parameter: [{$messagePostfix}]");
92
            return new Response(
93
                $this->cas10Protocol->getValidateFailureResponse(),
94
                Response::HTTP_BAD_REQUEST,
95
            );
96
        }
97
98
        try {
99
            // Get the service ticket
100
            // `getTicket` uses the unserializable method and Objects may throw Throwables in their
101
            // unserialization handlers.
102
            $serviceTicket = $this->ticketStore->getTicket($ticket);
103
            // Delete the ticket
104
            $this->ticketStore->deleteTicket($ticket);
105
        } catch (\Exception $e) {
106
            Logger::error('casserver:validate: internal server error. ' . var_export($e->getMessage(), true));
107
            return new Response(
108
                $this->cas10Protocol->getValidateFailureResponse(),
109
                Response::HTTP_INTERNAL_SERVER_ERROR,
110
            );
111
        }
112
113
        $failed = false;
114
        $message = '';
115
        if (empty($serviceTicket)) {
116
            // No ticket
117
            $message = 'ticket: ' . var_export($ticket, true) . ' not recognized';
118
            $failed = true;
119
        } elseif (!$this->ticketFactory->isServiceTicket($serviceTicket)) {
120
            // This is not a service ticket
121
            $message = 'ticket: ' . var_export($ticket, true) . ' is not a service ticket';
122
            $failed = true;
123
        } elseif ($this->ticketFactory->isExpired($serviceTicket)) {
124
            // the ticket has expired
125
            $message = 'Ticket has ' . var_export($ticket, true) . ' expired';
126
            $failed = true;
127
        } elseif ($this->sanitize($serviceTicket['service']) !== $this->sanitize($service)) {
128
            // The service url we passed to the query parameters does not match the one in the ticket.
129
            $message = 'Mismatching service parameters: expected ' .
130
                var_export($serviceTicket['service'], true) .
131
                ' but was: ' . var_export($service, true);
132
            $failed = true;
133
        } elseif ($forceAuthn && !$serviceTicket['forceAuthn']) {
134
            // If `forceAuthn` is required but not set in the ticket
135
            $message = 'Ticket was issued from single sign on session';
136
            $failed = true;
137
        }
138
139
        if ($failed) {
140
            Logger::error('casserver:validate: ' . $message);
141
            return new Response(
142
                $this->cas10Protocol->getValidateFailureResponse(),
143
                Response::HTTP_BAD_REQUEST,
144
            );
145
        }
146
147
        // Fail if the username is not present in the ticket
148
        if (empty($serviceTicket['userName'])) {
149
            return new Response(
150
                $this->cas10Protocol->getValidateFailureResponse(),
151
                Response::HTTP_BAD_REQUEST,
152
            );
153
        }
154
155
        // Successful validation
156
        return new Response(
157
            $this->cas10Protocol->getValidateSuccessResponse($serviceTicket['userName']),
158
            Response::HTTP_OK,
159
        );
160
    }
161
162
    /**
163
     * Used by the unit tests
164
     *
165
     * @return TicketStore
166
     */
167
    public function getTicketStore(): TicketStore
168
    {
169
        return $this->ticketStore;
170
    }
171
}
172