Passed
Push — master ( f3f6fc...a51c4b )
by Arnold
06:17
created

Server::getRequiredQueryParam()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 4
c 1
b 0
f 0
dl 0
loc 9
ccs 5
cts 5
cp 1
rs 10
cc 2
nc 2
nop 2
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Jasny\SSO\Server;
6
7
use Jasny\Immutable;
8
use Psr\Http\Message\ServerRequestInterface;
9
use Psr\Log\LoggerInterface;
10
use Psr\Log\NullLogger;
11
use Psr\SimpleCache\CacheInterface;
12
13
/**
14
 * Single sign-on server.
15
 * The SSO server is responsible of managing users sessions which are available for brokers.
16
 */
17
class Server
18
{
19
    use Immutable\With;
20
21
    /**
22
     * Callback to get the secret for a broker.
23
     * @var \Closure
24
     */
25
    protected $getBrokerInfo;
26
27
    /**
28
     * Storage for broker session links.
29
     * @var CacheInterface
30
     */
31
    protected $cache;
32
33
    /**
34
     * @var LoggerInterface
35
     */
36
    protected $logger;
37
38
    /**
39
     * Service to interact with sessions.
40
     * @var SessionInterface
41
     */
42
    protected $session;
43
44
    /**
45
     * Class constructor.
46
     *
47
     * @phpstan-param callable(string):?array{secret:string,domains:string[]} $getBrokerInfo
48
     * @phpstan-param CacheInterface                                          $cache
49
     */
50 20
    public function __construct(callable $getBrokerInfo, CacheInterface $cache)
51
    {
52 20
        $this->getBrokerInfo = \Closure::fromCallable($getBrokerInfo);
53 20
        $this->cache = $cache;
54
55 20
        $this->logger = new NullLogger();
56 20
        $this->session = new GlobalSession();
57 20
    }
58
59
    /**
60
     * Get a copy of the service with logging.
61
     *
62
     * @return static
63
     */
64 19
    public function withLogger(LoggerInterface $logger): self
65
    {
66 19
        return $this->withProperty('logger', $logger);
67
    }
68
69
    /**
70
     * Get a copy of the service with a custom session service.
71
     *
72
     * @return static
73
     */
74 20
    public function withSession(SessionInterface $session): self
75
    {
76 20
        return $this->withProperty('session', $session);
77
    }
78
79
80
    /**
81
     * Start the session for broker requests to the SSO server.
82
     *
83
     * @throws BrokerException
84
     * @throws ServerException
85
     */
86 8
    public function startBrokerSession(?ServerRequestInterface $request = null): void
87
    {
88 8
        if ($this->session->isActive()) {
89 1
            throw new ServerException("Session is already started", 500);
90
        }
91
92 7
        $bearer = $this->getBearerToken($request);
93
94 5
        [$brokerId, $token, $checksum] = $this->parseBearer($bearer);
95
96 4
        $sessionId = $this->cache->get($this->getCacheKey($brokerId, $token));
97
98 4
        if ($sessionId === null) {
99 1
            $this->logger->warning(
100 1
                "Bearer token isn't attached to a client session",
101 1
                ['broker' => $brokerId, 'token' => $token]
102
            );
103 1
            throw new BrokerException("Bearer token isn't attached to a client session", 403);
104
        }
105
106 3
        $code = $this->getVerificationCode($brokerId, $token, $sessionId);
107 3
        $this->validateChecksum($checksum, 'bearer', $brokerId, $token, $code);
108
109 1
        $this->session->resume($sessionId);
110
111 1
        $this->logger->debug(
112 1
            "Broker request with session",
113 1
            ['broker' => $brokerId, 'token' => $token, 'session' => $sessionId]
114
        );
115 1
    }
116
117
    /**
118
     * Get bearer token from Authorization header.
119
     */
120 7
    protected function getBearerToken(?ServerRequestInterface $request = null): string
121
    {
122 7
        $authorization = $request === null
123
            ? ($_SERVER['HTTP_AUTHORIZATION'] ?? '') // @codeCoverageIgnore
124 7
            : $request->getHeaderLine('Authorization');
125
126 7
        [$type, $token] = explode(' ', $authorization, 2) + ['', ''];
127
128 7
        if ($type !== 'Bearer') {
129 2
            $this->logger->warning("Broker didn't use bearer authentication: "
130 2
                . ($authorization === '' ? "No 'Authorization' header" : "$type authorization used"));
131 2
            throw new BrokerException("Broker didn't use bearer authentication", 401);
132
        }
133
134 5
        return $token;
135
    }
136
137
    /**
138
     * Get the broker id and token from the bearer token used by the broker.
139
     *
140
     * @return string[]
141
     * @throws BrokerException
142
     */
143 5
    protected function parseBearer(string $bearer): array
144
    {
145 5
        $matches = null;
146
147 5
        if (!(bool)preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) {
148 1
            $this->logger->warning("Invalid bearer token", ['bearer' => $bearer]);
149 1
            throw new BrokerException("Invalid bearer token", 403);
150
        }
151
152 4
        return array_slice($matches, 1);
153
    }
154
155
    /**
156
     * Generate cache key for linking the broker token to the client session.
157
     */
158 8
    protected function getCacheKey(string $brokerId, string $token): string
159
    {
160 8
        return "SSO-{$brokerId}-{$token}";
161
    }
162
163
    /**
164
     * Get the broker secret using the configured callback.
165
     *
166
     * @param string $brokerId
167
     * @return string|null
168
     */
169 12
    protected function getBrokerSecret(string $brokerId): ?string
170
    {
171 12
        return ($this->getBrokerInfo)($brokerId)['secret'] ?? null;
172
    }
173
174
    /**
175
     * Generate the verification code based on the token using the server secret.
176
     */
177 5
    protected function getVerificationCode(string $brokerId, string $token, string $sessionId): string
178
    {
179 5
        return base_convert(hash('sha256', $brokerId . $token . $sessionId), 16, 36);
180
    }
181
182
    /**
183
     * Generate checksum for a broker.
184
     */
185 12
    protected function generateChecksum(string $command, string $brokerId, string $token): string
186
    {
187 12
        $secret = $this->getBrokerSecret($brokerId);
188
189 12
        if ($secret === null) {
190 2
            $this->logger->warning("Unknown broker", ['broker' => $brokerId, 'token' => $token]);
191 2
            throw new BrokerException("Broker is unknown or disabled", 403);
192
        }
193
194 10
        return base_convert(hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36);
195
    }
196
197
    /**
198
     * Assert that the checksum matches the expected checksum.
199
     *
200
     * @throws BrokerException
201
     */
202 12
    protected function validateChecksum(
203
        string $checksum,
204
        string $command,
205
        string $brokerId,
206
        string $token,
207
        ?string $code = null
208
    ): void {
209 12
        $expected = $this->generateChecksum($command . ($code !== null ? ":$code" : ''), $brokerId, $token);
210
211 10
        if ($checksum !== $expected) {
212 2
            $this->logger->warning(
213 2
                "Invalid $command checksum",
214 2
                ['expected' => $expected, 'received' => $checksum, 'broker' => $brokerId, 'token' => $token]
215 2
                    + ($code !== null ? ['verification_code' => $code] : [])
216
            );
217 2
            throw new BrokerException("Invalid $command checksum", 403);
218
        }
219 8
    }
220
221
    /**
222
     * Validate that the URL has a domain that is allowed for the broker.
223
     */
224 5
    public function validateDomain(string $type, string $url, string $brokerId, ?string $token = null): void
225
    {
226 5
        $domains = ($this->getBrokerInfo)($brokerId)['domains'] ?? [];
227 5
        $host = parse_url($url, PHP_URL_HOST);
228
229 5
        if (!in_array($host, $domains, true)) {
230 3
            $this->logger->warning(
231 3
                "Domain of $type is not allowed for broker",
232 3
                [$type => $url, 'broker' => $brokerId] + ($token !== null ? ['token' => $token] : [])
233
            );
234 3
            throw new BrokerException("Domain of $type is not allowed", 400);
235
        }
236 4
    }
237
238
    /**
239
     * Attach a client session to a broker session.
240
     * Returns the verification code.
241
     *
242
     * @throws BrokerException
243
     * @throws ServerException
244
     */
245 12
    public function attach(?ServerRequestInterface $request = null): string
246
    {
247 12
        ['broker' => $brokerId, 'token' => $token] = $this->processAttachRequest($request);
248
249 4
        $this->session->start();
250
251 4
        $this->assertNotAttached($brokerId, $token);
252
253 3
        $key = $this->getCacheKey($brokerId, $token);
254 3
        $cached = $this->cache->set($key, $this->session->getId());
255
256 3
        $info = ['broker' => $brokerId, 'token' => $token, 'session' => $this->session->getId()];
257
258 3
        if (!$cached) {
259 1
            $this->logger->error("Failed to attach bearer token to session id due to cache issue", $info);
260 1
            throw new ServerException("Failed to attach bearer token to session id", 500);
261
        }
262
263 2
        $this->logger->info("Attached broker token to session", $info);
264
265 2
        return $this->getVerificationCode($brokerId, $token, $this->session->getId());
266
    }
267
268
    /**
269
     * Assert that the token isn't already attached to a different session.
270
     */
271 4
    protected function assertNotAttached(string $brokerId, string $token): void
272
    {
273 4
        $key = $this->getCacheKey($brokerId, $token);
274 4
        $attached = $this->cache->get($key);
275
276 4
        if ($attached !== null && $attached !== $this->session->getId()) {
277 1
            $this->logger->warning("Token is already attached", [
278 1
                'broker' => $brokerId,
279 1
                'token' => $token,
280 1
                'attached_to' => $attached,
281 1
                'session' => $this->session->getId()
282
            ]);
283 1
            throw new BrokerException("Token is already attached", 400);
284
        }
285 3
    }
286
287
    /**
288
     * Validate attach request and return broker id and token.
289
     *
290
     * @param ServerRequestInterface|null $request
291
     * @return array{broker:string,token:string}
292
     * @throws BrokerException
293
     */
294 12
    protected function processAttachRequest(?ServerRequestInterface $request): array
295
    {
296 12
        $brokerId = $this->getRequiredQueryParam($request, 'broker');
297 11
        $token = $this->getRequiredQueryParam($request, 'token');
298 10
        $checksum = $this->getRequiredQueryParam($request, 'checksum');
299
300 9
        $this->validateChecksum($checksum, 'attach', $brokerId, $token);
301
302 7
        $origin = $this->getHeader($request, 'Origin');
303 7
        if ($origin !== '') {
304 5
            $this->validateDomain('origin', $origin, $brokerId, $token);
305
        }
306
307 6
        $referer = $this->getHeader($request, 'Referer');
308 6
        if ($referer !== '') {
309 4
            $this->validateDomain('referer', $referer, $brokerId, $token);
310
        }
311
312 5
        $returnUrl = $this->getQueryParam($request, 'return_url');
313 5
        if ($returnUrl !== null) {
314 3
            $this->validateDomain('return_url', $returnUrl, $brokerId, $token);
315
        }
316
317 4
        return ['broker' => $brokerId, 'token' => $token];
318
    }
319
320
    /**
321
     * Get query parameter from PSR-7 request or $_GET.
322
     */
323 12
    protected function getQueryParam(?ServerRequestInterface $request, string $key): ?string
324
    {
325 12
        $params = $request === null
326
            ? $_GET // @codeCoverageIgnore
327 12
            : $request->getQueryParams();
328
329 12
        return $params[$key] ?? null;
330
    }
331
332
    /**
333
     * Get required query parameter from PSR-7 request or $_GET.
334
     *
335
     * @throws BrokerException if query parameter isn't set
336
     */
337 12
    protected function getRequiredQueryParam(?ServerRequestInterface $request, string $key): string
338
    {
339 12
        $value = $this->getQueryParam($request, $key);
340
341 12
        if ($value === null) {
342 3
            throw new BrokerException("Missing '$key' query parameter", 400);
343
        }
344
345 11
        return $value;
346
    }
347
348
    /**
349
     * Get HTTP Header from PSR-7 request or $_SERVER.
350
     *
351
     * @param ServerRequestInterface $request
352
     * @param string                 $key
353
     * @return string
354
     */
355 7
    protected function getHeader(?ServerRequestInterface $request, string $key): string
356
    {
357 7
        return $request === null
358
            ? ($_SERVER['HTTP_' . str_replace('-', '_', strtoupper($key))] ?? '') // @codeCoverageIgnore
359 7
            : $request->getHeaderLine($key);
360
    }
361
}
362