Failed Conditions
Pull Request — master (#127)
by
unknown
05:30
created

src/Broker/Broker.php (1 issue)

Severity
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Jasny\SSO\Broker;
6
7
use Jasny\Immutable;
8
9
/**
10
 * Single sign-on broker.
11
 *
12
 * The broker lives on the website visited by the user. The broken doesn't have any user credentials stored. Instead it
13
 * will talk to the SSO server in name of the user, verifying credentials and getting user information.
14
 */
15
class Broker
16
{
17
    use Immutable\With;
18
19
    /**
20
     * URL of SSO server.
21
     * @var string
22
     */
23
    protected $url;
24
25
    /**
26
     * My identifier, given by SSO provider.
27
     * @var string
28
     */
29
    protected $broker;
30
31
    /**
32
     * My secret word, given by SSO provider.
33
     * @var string
34
     */
35
    protected $secret;
36
37
    /**
38
     * @var bool
39
     */
40
    protected $initialized = false;
41
42
    /**
43
     * Session token of the client.
44
     * @var string|null
45
     */
46
    protected $token;
47
48
    /**
49
     * Verification code returned by the server.
50
     * @var string|null
51
     */
52
    protected $verificationCode;
53
54
    /**
55
     * @var \ArrayAccess<string,mixed>
56
     */
57
    protected $state;
58
59
    /**
60
     * @var Curl
61
     */
62
    protected $curl;
63
64
    /**
65
     * Class constructor
66
     *
67
     * @param string $url Url of SSO server
68
     * @param string $broker My identifier, given by SSO provider.
69
     * @param string $secret My secret word, given by SSO provider.
70
     * @param array $cookieOptions Array of 4 possible cookie options (ttl, path, domain, secure)
71
     */
72 17
    public function __construct(string $url, string $broker, string $secret, array $cookieOptions = [])
0 ignored issues
show
Method Jasny\SSO\Broker\Broker::__construct() has parameter $cookieOptions with no value type specified in iterable type array.
Loading history...
73
    {
74 17
        if (!(bool)preg_match('~^https?://~', $url)) {
75 1
            throw new \InvalidArgumentException("Invalid SSO server URL '$url'");
76
        }
77
78 17
        if ((bool)preg_match('/\W/', $broker)) {
79 1
            throw new \InvalidArgumentException("Invalid broker id '$broker': must be alphanumeric");
80
        }
81
82 17
        $this->url = $url;
83 17
        $this->broker = $broker;
84 17
        $this->secret = $secret;
85
86 17
        $ttl = $cookieOptions['ttl'] ?? 3600;
87 17
        $path = $cookieOptions['path'] ?? '';
88 17
        $domain = $cookieOptions['domain'] ?? '';
89 17
        $secure = $cookieOptions['secure'] ?? false;
90
91 17
        $this->state = new Cookies($ttl, $path, $domain, $secure);
92 17
    }
93
94
    /**
95
     * Get a copy with a different handler for the user state (like cookie or session).
96
     *
97
     * @param \ArrayAccess<string,mixed> $handler
98
     * @return static
99
     */
100 17
    public function withTokenIn(\ArrayAccess $handler): self
101
    {
102 17
        return $this->withProperty('state', $handler);
103
    }
104
105
    /**
106
     * Set a custom wrapper for cURL.
107
     *
108
     * @param Curl $curl
109
     * @return static
110
     */
111 17
    public function withCurl(Curl $curl): self
112
    {
113 17
        return $this->withProperty('curl', $curl);
114
    }
115
116
    /**
117
     * Get Wrapped cURL.
118
     */
119 6
    protected function getCurl(): Curl
120
    {
121 6
        if (!isset($this->curl)) {
122
            $this->curl = new Curl(); // @codeCoverageIgnore
123
        }
124
125 6
        return $this->curl;
126
    }
127
128
    /**
129
     * Get the broker identifier.
130
     */
131 1
    public function getBrokerId(): string
132
    {
133 1
        return $this->broker;
134
    }
135
136
    /**
137
     * Get information from cookie.
138
     */
139 14
    protected function initialize(): void
140
    {
141 14
        if ($this->initialized) {
142 12
            return;
143
        }
144
145 14
        $this->token = $this->state[$this->getCookieName('token')] ?? null;
146 14
        $this->verificationCode = $this->state[$this->getCookieName('verify')] ?? null;
147 14
        $this->initialized = true;
148 14
    }
149
150
    /**
151
     * @return string|null
152
     */
153 10
    protected function getToken(): ?string
154
    {
155 10
        $this->initialize();
156
157 10
        return $this->token;
158
    }
159
160
    /**
161
     * @return string|null
162
     */
163 11
    protected function getVerificationCode(): ?string
164
    {
165 11
        $this->initialize();
166
167 11
        return $this->verificationCode;
168
    }
169
170
    /**
171
     * Get the cookie name.
172
     * The broker name is part of the cookie name. This resolves issues when multiple brokers are on the same domain.
173
     */
174 14
    protected function getCookieName(string $type): string
175
    {
176 14
        $brokerName = preg_replace('/[_\W]+/', '_', strtolower($this->broker));
177
178 14
        return "sso_{$type}_{$brokerName}";
179
    }
180
181
    /**
182
     * Generate session id from session key
183
     *
184
     * @throws NotAttachedException
185
     */
186 8
    public function getBearerToken(): string
187
    {
188 8
        $token = $this->getToken();
189 8
        $verificationCode = $this->getVerificationCode();
190
191 8
        if ($verificationCode === null) {
192 1
            throw new NotAttachedException("The client isn't attached to the SSO server for this broker. "
193 1
                . "Make sure that the '" . $this->getCookieName('verify') . "' cookie is set.");
194
        }
195
196 7
        return "SSO-{$this->broker}-{$token}-" . $this->generateChecksum("bearer:$verificationCode");
197
    }
198
199
    /**
200
     * Generate session token.
201
     */
202 2
    protected function generateToken(): void
203
    {
204 2
        $this->token = base_convert(bin2hex(random_bytes(32)), 16, 36);
205 2
        $this->state[$this->getCookieName('token')] = $this->token;
206 2
    }
207
208
    /**
209
     * Clears session token.
210
     */
211 1
    public function clearToken(): void
212
    {
213 1
        unset($this->state[$this->getCookieName('token')]);
214 1
        unset($this->state[$this->getCookieName('verify')]);
215
216 1
        $this->token = null;
217 1
        $this->verificationCode = null;
218 1
    }
219
220
    /**
221
     * Check if we have an SSO token.
222
     */
223 5
    public function isAttached(): bool
224
    {
225 5
        return $this->getVerificationCode() !== null;
226
    }
227
228
    /**
229
     * Get URL to attach session at SSO server.
230
     *
231
     * @param array<string,mixed> $params
232
     * @return string
233
     */
234 2
    public function getAttachUrl(array $params = []): string
235
    {
236 2
        if ($this->getToken() === null) {
237 2
            $this->generateToken();
238
        }
239
240
        $data = [
241 2
            'broker' => $this->broker,
242 2
            'token' => $this->getToken(),
243 2
            'checksum' => $this->generateChecksum('attach')
244
        ];
245
246 2
        return $this->url . "?" . http_build_query($data + $params);
247
    }
248
249
    /**
250
     * Verify attaching to the SSO server by providing the verification code.
251
     */
252 3
    public function verify(string $code): void
253
    {
254 3
        $this->initialize();
255
256 3
        if ($this->verificationCode === $code) {
257 1
            return;
258
        }
259
260 2
        if ($this->verificationCode !== null) {
261 1
            trigger_error("SSO attach already verified", E_USER_WARNING);
262 1
            return;
263
        }
264
265 1
        $this->verificationCode = $code;
266 1
        $this->state[$this->getCookieName('verify')] = $code;
267 1
    }
268
269
    /**
270
     * Generate checksum for a broker.
271
     */
272 9
    protected function generateChecksum(string $command): string
273
    {
274 9
        return base_convert(hash_hmac('sha256', $command . ':' . $this->token, $this->secret), 16, 36);
275
    }
276
277
    /**
278
     * Get the request url for a command
279
     *
280
     * @param string                     $path
281
     * @param array<string,mixed>|string $params   Query parameters
282
     * @return string
283
     */
284 6
    protected function getRequestUrl(string $path, $params = ''): string
285
    {
286 6
        $query = is_array($params) ? http_build_query($params) : $params;
287
288 6
        $base = $path[0] === '/'
289 6
            ? preg_replace('~^(\w+://[^/]+).*~', '$1', $this->url)
290 6
            : preg_replace('~/[^/]*$~', '', $this->url);
291
292 6
        return $base . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : '');
293
    }
294
295
296
    /**
297
     * Send an HTTP request to the SSO server.
298
     *
299
     * @param string                     $method  HTTP method: 'GET', 'POST', 'DELETE'
300
     * @param string                     $path    Relative path
301
     * @param array<string,mixed>|string $data    Query or post parameters
302
     * @return mixed
303
     * @throws RequestException
304
     */
305 6
    public function request(string $method, string $path, $data = '')
306
    {
307 6
        $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data);
308
        $headers = [
309 6
            'Accept: application/json',
310 6
            'Authorization: Bearer ' . $this->getBearerToken()
311
        ];
312
313 6
        ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $body] =
314 6
            $this->getCurl()->request($method, $url, $headers, $method === 'POST' ? $data : '');
315
316 6
        return $this->handleResponse($httpCode, $contentType, $body);
317
    }
318
319
    /**
320
     * Handle the response of the cURL request.
321
     *
322
     * @param int    $httpCode  HTTP status code
323
     * @param string $ctHeader  Content-Type header
324
     * @param string $body      Response body
325
     * @return mixed
326
     * @throws RequestException
327
     */
328 6
    protected function handleResponse(int $httpCode, string $ctHeader, string $body)
329
    {
330 6
        if ($httpCode === 204) {
331 1
            return null;
332
        }
333
334 5
        [$contentType] = explode(';', $ctHeader, 2);
335
336 5
        if ($contentType != 'application/json') {
337 1
            throw new RequestException(
338 1
                "Expected 'application/json' response, got '$contentType'",
339 1
                500,
340 1
                new RequestException($body, $httpCode)
341
            );
342
        }
343
344
        try {
345 4
            $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
346 1
        } catch (\JsonException $exception) {
347 1
            throw new RequestException("Invalid JSON response from server", 500, $exception);
348
        }
349
350 3
        if ($httpCode >= 400) {
351 1
            throw new RequestException($data['error'] ?? $body, $httpCode);
352
        }
353
354 2
        return $data;
355
    }
356
}
357