Tiqr_Service::sendAuthNotification()   B
last analyzed

Complexity

Conditions 8
Paths 55

Size

Total Lines 42
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 72

Importance

Changes 12
Bugs 0 Features 1
Metric Value
eloc 30
c 12
b 0
f 1
dl 0
loc 42
ccs 0
cts 29
cp 0
rs 8.1954
cc 8
nc 55
nop 3
crap 72
1
<?php
2
/**
3
 * This file is part of the tiqr project.
4
 * 
5
 * The tiqr project aims to provide an open implementation for 
6
 * authentication using mobile devices. It was initiated by 
7
 * SURFnet and developed by Egeniq.
8
 *
9
 * More information: http://www.tiqr.org
10
 *
11
 * @author Ivo Jansch <[email protected]>
12
 * 
13
 * @package tiqr
14
 *
15
 * @license New BSD License - See LICENSE file for details.
16
 *
17
 * @copyright (C) 2010-2011 SURFnet BV
18
 */
19
20
use chillerlan\QRCode\QRCode;
21
use chillerlan\QRCode\QROptions;
22
use Psr\Log\LoggerInterface;
23
24
/** 
25
 * The main Tiqr Service class.
26
 * This is the class that an application interacts with to implement authentication and enrollment using the tiqr
27
 * protocol, used with the tiqr.org mobile authentication apps
28
 * See https://tiqr.org/technical/protocol/ for a specification of the protocol
29
 */
30
class Tiqr_Service
31
{
32
    /**
33
     * @internal Various variables internal to the service class
34
     */
35
    /** @var array  */
36
    protected $_options;
37
38
    /** @var string */
39
    protected $_protocolAuth;
40
    /** @var string */
41
    protected $_protocolEnroll;
42
    /** @var string */
43
    protected $_identifier;
44
    /** @var string */
45
    protected $_ocraSuite;
46
    /** @var string */
47
    protected $_name;
48
    /** @var string */
49
    protected $_logoUrl;
50
    /** @var string */
51
    protected $_infoUrl;
52
    /** @var int */
53
    protected $_protocolVersion;
54
    /** @var Tiqr_StateStorage_StateStorageInterface */
55
    protected $_stateStorage;
56
    /** @var Tiqr_DeviceStorage_Abstract */
57
    protected $_deviceStorage;
58
    /** @var Tiqr_OcraService_Interface */
59
    protected $_ocraService;
60
    /** @var string */
61
    protected $_stateStorageSalt; // The salt used for creating stable hashes for use with the StateStorage
62
63
    /** @var LoggerInterface */
64
    private $logger;
65
66
    /**
67
     * Enrollment status codes
68
     */
69
    // IDLE: There is no enrollment going on in this session, or there was an error getting the enrollment status
70
    const ENROLLMENT_STATUS_IDLE = 1;
71
    // INITIALIZED: The enrollment session was started, but the tiqr client has not retrieved the metadata yet
72
    const ENROLLMENT_STATUS_INITIALIZED = 2;
73
    // RETRIEVED: The tiqr client has retrieved the metadata
74
    const ENROLLMENT_STATUS_RETRIEVED = 3;
75
    // PROCESSED: The tiqr client has sent back the tiqr authentication secret
76
    const ENROLLMENT_STATUS_PROCESSED = 4;
77
    // FINALIZED: The server has stored the authentication secret
78
    const ENROLLMENT_STATUS_FINALIZED = 5;
79
    // VALIDATED: A first successful authentication was performed
80
    // Note: Not currently used
81
    const ENROLLMENT_STATUS_VALIDATED = 6;
82
83
    /**
84
     * Prefixes for StateStorage keys
85
     */
86
    const PREFIX_ENROLLMENT_SECRET = 'enrollsecret';
87
    const PREFIX_ENROLLMENT = 'enroll';
88
    const PREFIX_CHALLENGE = 'challenge';
89
    const PREFIX_ENROLLMENT_STATUS = 'enrollstatus';
90
    const PREFIX_AUTHENTICATED = 'authenticated_';
91
92
    /**
93
     * Default timeout values
94
     */
95
    const LOGIN_EXPIRE      = 3600; // Logins timeout after an hour
96
    const ENROLLMENT_EXPIRE = 300; // If enrollment isn't completed within 5 minutes, we discard data
97
    const CHALLENGE_EXPIRE  = 180; // If login is not performed within 3 minutes, we discard the challenge
98
99
    /**
100
     * Authentication result codes
101
     */
102
    // INVALID_REQUEST: Not currently used by the Tiqr_service
103
    const AUTH_RESULT_INVALID_REQUEST   = 1;
104
    // AUTHENTICATED: The user was successfully authenticated
105
    const AUTH_RESULT_AUTHENTICATED     = 2;
106
    // INVALID_RESPONSE: The response that was returned by the client was not correct
107
    const AUTH_RESULT_INVALID_RESPONSE  = 3;
108
    // INVALID_CHALLENGE: The server could find the challenge in its state storage. It may have been expired or the
109
    // client could have sent an invalid sessionKey
110
    const AUTH_RESULT_INVALID_CHALLENGE = 4;
111
    // INVALID_USERID: The client authenticated a different user than the server expected. This error is returned when
112
    // the application stated an authentication session specifying the userId and later during the authentication
113
    // provides a different userId
114
    const AUTH_RESULT_INVALID_USERID    = 5;
115
    
116
    /**
117
     * The default OCRA Suite (RFC 6287) to use for authentication in Tiqr
118
     * This basically calculates the HMAC-SHA1 over a buffer with:
119
     * - A 10 hex digit long challenge
120
     * - authentication session ID (32 hex digits)
121
     * - client secret key (64 hex digits)
122
     * and then from the calculated HMAC-SHA1 calculates a 6 decimal digit long response
123
     *
124
     * Refer to the included SECURITY.md for security consideration
125
     */
126
    const DEFAULT_OCRA_SUITE = "OCRA-1:HOTP-SHA1-6:QH10-S";
127
128
    /**
129
     * session keys are used in multiple places during authentication and enrollment
130
     * and are generated by _uniqueSessionKey() using a secure pseudo-random number generator
131
     * SESSION_KEY_LENGTH_BYTES specifies the number of bytes of entropy in these keys.
132
     * The OCRA session keys are HEX encoded, so a 16 byte key (128 bits) will be 32 characters long
133
     */
134
    const SESSION_KEY_LENGTH_BYTES = 16;
135
136
    /**
137
     * Construct an instance of the Tiqr_Service. 
138
     * The server is configured using an array of options. All options have
139
     * reasonable defaults but it's recommended to at least specify a custom 
140
     * name and identifier and a randomly generated sessions secret.
141
     * If you use the Tiqr Service with your own apps, you must also specify
142
     * a custom auth.protocol and enroll.protocol specifier.
143
     * 
144
     * The options are:
145
     * - auth.protocol: The protocol specifier that the server uses to communicate challenge urls to the
146
     *                  iOS/Android tiqr app. This must match the url handler configuration in the app's build
147
     *                  settings.
148
     *                  Default: "tiqr".
149
     *                  Two formats are supported:
150
     *                  1. Custom URL scheme: Set the scheme's name. E.g. "tiqrauth". Do not add '://'.
151
     *                     This will generate authentication URLs of the form:
152
     *                     tiqrauth://<userId>@<idp_identifier>/<session_key>/<challenge>/<sp_idenitfier>/<version>
153
     *                  2. Universal link: Set the http or https URL. E.g. "https://tiqr.org/tiqrauth/"
154
     *                     This will generate authentication URLs of the form:
155
     *                     https://tiqr.org/tiqrauth/?u=<userid>&s=<session_key>&q=<challenge/question>&i=<idp_identifier>&v=<version>
156
     *
157
     * - enroll.protocol: The protocol specifier that the server uses to start the enrollment of a new account in the
158
     *                    iOS/Android tiqr app. This must match the url handler configuration in the app's build
159
     *                    settings.
160
     *                    Default: "tiqrenroll"
161
     *                    Two formats are supported:
162
     *                    1. Custom URL scheme: Set the protocol name. E.g. "tiqrenroll". Do not add '://'.
163
     *                       This will generate enrollment URLs of the form:
164
     *                       tiqrenroll://<metadata URL>
165
     *                    2. Universal link: Set the http or https URL. "https://tiqr.org/tiqrenroll/"
166
     *                       This will generate enrollment URLs of the form:
167
     *                       https://eduid.nl/tiqrenroll/?metadata=<URL encoded metadata URL>
168
     *
169
     * - ocra.suite: The OCRA suite to use. Defaults to DEFAULT_OCRA_SUITE.
170
     *
171
     * - identifier: A short ASCII identifier for your service. Defaults to the SERVER_NAME of the server. This is what
172
     *               a tiqr client will use to identify the server.
173
     * - name: A longer description of your service. Defaults to the SERVER_NAME of the server. A descriptive name for
174
     *         display purposes
175
     *
176
     * - logoUrl: A full http url pointing to a logo for your service.
177
     * - infoUrl: An http url pointing to an info page of your service
178
     *
179
     * - ocraservice: Configuration for the OcraService to use.
180
     *                - type: The ocra service type. (default: "tiqr")
181
     *                - parameters depending on the ocra service. See classes inside to OcraService directory for
182
     *                  supported types and their parameters.
183
     *
184
     * - statestorage: An array with the configuration of the storage for temporary data. It has the following sub keys:
185
     *                 - type: The type of state storage. (default: "file")
186
     *                 - salt: The salt is used to hash the keys used the StateStorage
187
     *                 - parameters depending on the storage. See the classes inside the StateStorage folder for
188
     *                   supported types and their parameters.
189
     *
190
     *
191
     *  * For sending push notifications using the Apple push notification service (APNS)
192
     * - apns.certificate: The location of the file with the Apple push notification client certificate and private key
193
     *                     in PEM format.
194
     *                     Defaults to ../certificates/cert.pem
195
     * - apns.environment: Whether to use apple's "sandbox" or "production" apns environment
196
     * - apns.version:     Which version of the APNS protocol to use. Default: 2
197
     *                     Version 1 is the deprecated binary APNS protocol and is no longer supported
198
     *                     Version 2: The HTTP/2 based protocol (api.push.apple.com)
199
     * - apns.proxy_host_url: Use a HTTP/1.1 to HTTP/2 proxy to send the apns.version 2 push notification.
200
     *                        Note: The proxy must take care of the TLS Client authentication to the APNS server
201
     *                        Note: The apns.environment will have no effect, configure this in the proxy
202
     *                        Specify the host URL as scheme + hostname. E.g.: "http://localhost"
203
     * - apns.proxy_host_port: Set the proxy port to use with proxy_host_url. Optional. Defaults to 443.
204
     *
205
     * * For sending push notifications to Android devices using Google's firebase cloud messaging (FCM) API
206
     * - firebase.projectId: String containing the FCM project ID
207
     * - firebase.credentialsFile: the name of the json file containing the service account key
208
     *
209
     * - devicestorage: An array with the configuration of the storage for device push notification tokens. Only
210
     *                  necessary if you use the Tiqr Service to authenticate an already known userId (e.g. when using
211
     *                  tiqr a second authentication factor AND are using a tiqr client that uses the token exchange.
212
     *                  It has the following
213
     *                  keys:
214
     *                  - type: The type of  storage. (default: "dummy")
215
     *                  - parameters depending on the storage. See the classes inside the DeviceStorage folder for
216
     *                    supported types and their parameters.
217
     **
218
     * @param LoggerInterface $logger
219
     * @param array $options
220
     * @param int $version The tiqr protocol version to use (defaults to the latest)
221
     * @throws Exception
222
     */
223 8
    public function __construct(LoggerInterface $logger, array $options=array(), int $version = 2)
224
    {
225 8
        $this->_options = $options; // Used to later get settings for Tiqr_Message_*
226 8
        $this->logger = $logger;
227 8
        $this->_protocolAuth = $options["auth.protocol"] ?? 'tiqr';
228 8
        $this->_protocolEnroll = $options["enroll.protocol"] ?? 'tiqrenroll';
229 8
        $this->_ocraSuite = $options["ocra.suite"] ?? self::DEFAULT_OCRA_SUITE;
230 8
        $this->_identifier = $options["identifier"] ?? $_SERVER["SERVER_NAME"];
231 8
        $this->_name = $options["name"] ?? $_SERVER["SERVER_NAME"];
232 8
        $this->_logoUrl = $options["logoUrl"] ?? '';
233 8
        $this->_infoUrl = $options["infoUrl"] ?? '';
234
235
        // An idea is to create getStateStorage, getDeviceStorage and getOcraService functions to create these functions
236
        // at the point that we actually need them.
237
238
        // Create StateStorage
239 8
        if (!isset($options["statestorage"])) {
240
            throw new RuntimeException('No state storage configuration is configured, please provide one');
241
        }
242 8
        $this->_stateStorage = Tiqr_StateStorage::getStorage($options["statestorage"]["type"], $options["statestorage"], $logger);
243
        // Set a default salt, with the SESSION_KEY_LENGTH_BYTES (16) length keys we're using a publicly
244
        // known salt already gives excellent protection.
245 7
        $this->_stateStorageSalt = $options["statestorage"]['salt'] ?? '8xwk2pFd';
246
247
        // Create DeviceStorage - required when using Push Notification with a token exchange
248 7
        if (isset($options["devicestorage"])) {
249 6
            $this->_deviceStorage = Tiqr_DeviceStorage::getStorage($options["devicestorage"]["type"], $options["devicestorage"], $logger);
250
        } else {
251 1
            $this->_deviceStorage = Tiqr_DeviceStorage::getStorage('dummy', array(), $logger);
252
        }
253
254
        // Set Tiqr protocol version, only version 2 is currently supported
255 7
        if ($version !== 2) {
256
            throw new Exception("Unsupported protocol version '$version'");
257
        }
258 7
        $this->_protocolVersion = $version;
259
260
        // Create OcraService
261
        // Library versions before 3.0 (confusingly) used the usersecretstorage key for this configuration
262
        // and used 'tiqr' as type when no type explicitly set to oathserviceclient was configured
263 7
        if (isset($options['ocraservice']) && $options['ocraservice']['type'] != 'tiqr') {
264
            $options['ocraservice']['ocra.suite'] = $this->_ocraSuite;
265
            $this->_ocraService = Tiqr_OcraService::getOcraService($options['ocraservice']['type'], $options['ocraservice'], $logger);
266
        }
267
        else { // Create default ocraservice
268 7
            $this->_ocraService = Tiqr_OcraService::getOcraService('tiqr', array('ocra.suite' => $this->_ocraSuite), $logger);
269
        }
270
    }
271
    
272
    /**
273
     * Get the identifier of the service.
274
     * @return String identifier
275
     */
276 4
    public function getIdentifier(): string
277
    {
278 4
        return $this->_identifier;
279
    }
280
    
281
    /**
282
     * Generate an authentication challenge QR image in PNG format and send it directly to
283
     * the PHP output buffer
284
     *
285
     * You are responsible for sending the "Content-type: image/png" HTTP header when sending this output to a
286
     * webbrowser, e.g.: header('Content-type: image/png')
287
     *
288
     * @param String $sessionKey The sessionKey identifying this auth session (typically returned by startAuthenticationSession)
289
     * @throws Exception
290
     *
291
     * @see generateAuthURL
292
     *
293
     */
294
    public function generateAuthQR(string $sessionKey): void
295
    {
296
        $challengeUrl = $this->_getChallengeUrl($sessionKey);
297
298
        $this->generateQR($challengeUrl);
299
    }
300
301
    /**
302
     * Generate a QR image in PNG format and send it directly to
303
     * the PHP output buffer
304
     *
305
     * You are responsible for sending the "Content-type: image/png" HTTP header when sending this output to a
306
     * webbrowser, e.g.: header('Content-type: image/png')
307
     *
308
     * @param String $s The string to be encoded in the QR image
309
     */
310
    public function generateQR(string $s): void
311
    {
312
        try {
313
            $options = new QROptions;
314
            $options->imageBase64 = false; // output raw image instead of base64 data URI
315
            $options->eccLevel = QRCode::ECC_L;
316
            $options->outputType = QRCode::OUTPUT_IMAGE_PNG;
317
            $options->scale = 5;
318
319
            echo (new QRCode($options))->render($s);
320
        } catch (Exception $e) {
321
            $this->logger->error(
322
                "Error generating QR code",
323
                array('exception' =>$e)
324
            );
325
            throw $e;
326
        }
327
    }
328
329
    /**
330
     * Send a push notification to a user containing an authentication challenge
331
     * @param String $sessionKey          The session key identifying this authentication session
332
     * @param String $notificationType    Notification type returned by the tiqr client: APNS, GCM, FCM, APNS_DIRECT or FCM_DIRECT
333
     * @param String $notificationAddress Notification address, e.g. device token, phone number etc.
334
     **
335
     * @throws Exception
336
     */
337
    public function sendAuthNotification(string $sessionKey, string $notificationType, string $notificationAddress): void
338
    {
339
        $message = NULL;
340
        try {
341
            switch ($notificationType) {
342
                case 'APNS':
343
                case 'APNS_DIRECT':
344
                    $apns_version = $this->_options['apns.version'] ?? 2;
345
                    if ($apns_version !=2)
346
                        throw new InvalidArgumentException("Unsupported APNS version '$apns_version'");
347
                    $message = new Tiqr_Message_APNS2($this->_options, $this->logger);
348
                    break;
349
350
                case 'GCM':
351
                case 'FCM':
352
                case 'FCM_DIRECT':
353
                    $message = new Tiqr_Message_FCM($this->_options, $this->logger);
354
                    break;
355
356
                default:
357
                    throw new InvalidArgumentException("Unsupported notification type '$notificationType'");
358
            }
359
360
            // Authentication timeout in seconds to send as payload in the push notification to the client. The Tiqr client
361
            // can use this value to stop offering the authentication to the user.
362
            // Use CHALLENGE_EXPIRE - 30 seconds as the maximum timeout to send to the client. This gives the user 30 seconds
363
            // before the authentication session expires at the server. Never send an authenticationTimeout of less than 30 seconds.
364
            $authenticationTimeout = max( 30, self::CHALLENGE_EXPIRE - 30);
365
366
            $this->logger->info(sprintf('Creating and sending a %s push notification', $notificationType));
367
            $message->setId(time());
368
            $message->setText("Please authenticate for " . $this->_name);
369
            $message->setAddress($notificationAddress);
370
            $message->setCustomProperty('challenge', $this->_getChallengeUrl($sessionKey));
371
            $message->setCustomProperty('authenticationTimeout', $authenticationTimeout);
372
            $message->send();
373
        } catch (Exception $e) {
374
            $this->logger->error(
375
                sprintf('Sending "%s" push notification to address "%s" failed', $notificationType, $notificationAddress),
376
                array('exception' =>$e)
377
            );
378
            throw $e;
379
        }
380
    }
381
382
    /** 
383
     * Generate an authentication challenge URL.
384
     * This URL can be used to link directly to the authentication
385
     * application, for example to create a link in a mobile website on the
386
     * same device as where the application is installed
387
     *
388
     * Opening the URL in the authentication application start the authentication
389
     * of a previously enrolled account.
390
     *
391
     * You can encode this URL in a QR code to scan it in the Tiqr app using you own
392
     * QR code library, or use generateQR()
393
     *
394
     *
395
     * @param String $sessionKey The session key identifying this authentication session
396
     *
397
     * @return string Authentication URL for the tiqr client
398
     * @throws Exception
399
     *
400
     * @see Tiqr_Service::generateQR()
401
     */
402 3
    public function generateAuthURL(string $sessionKey): string
403
    {
404 3
        $challengeUrl = $this->_getChallengeUrl($sessionKey);  
405
        
406 3
        return $challengeUrl;
407
    }
408
409
    /**
410
     * Start an authentication session. This generates a challenge for this
411
     * session and stores it in memory. The returned sessionKey should be used
412
     * throughout the authentication process.
413
     *
414
     * @param String $userId The userId of the user to authenticate (optional), if this is left empty the
415
     *                       the client decides
416
     * @param String $sessionId The session id the application uses to identify its user sessions;
417
     *                          (optional defaults to the php session id).
418
     *                          This sessionId can later be used to get the authenticated user from the application
419
     *                          using getAuthenticatedUser(), or to clear the authentication state using logout()
420
     * @param String $spIdentifier If SP and IDP are 2 different things, pass the url/identifier of the SP the user is logging into.
421
     *                             For setups where IDP==SP, just leave this blank.
422
     * @return string The authentication sessionKey
423
     * @throws Exception when starting the authentication session failed
424
     */
425 3
    public function startAuthenticationSession(string $userId="", string $sessionId="", string $spIdentifier=""): string
426
    {
427 3
        if ($sessionId=="") {
428 2
            $sessionId = session_id();
429
        }
430
431 3
        if ($spIdentifier=="") {
432 3
            $spIdentifier = $this->_identifier;
433
        }
434
435 3
        $sessionKey = $this->_uniqueSessionKey();
436 3
        $challenge = $this->_ocraService->generateChallenge();
437
        
438 3
        $data = array("sessionId"=>$sessionId, "challenge"=>$challenge, "spIdentifier" => $spIdentifier);
439
        
440 3
        if ($userId!="") {
441 2
            $data["userId"] = $userId;
442
        }
443
        
444 3
        $this->_setStateValue(self::PREFIX_CHALLENGE, $sessionKey, $data, self::CHALLENGE_EXPIRE);
445
       
446 3
        return $sessionKey;
447
    }
448
    
449
    /**
450
     * Start an enrollment session. This can either be the enrollment of a new 
451
     * user or of an existing user, there is no difference from Tiqr's point
452
     * of view.
453
     * 
454
     * The call returns the temporary enrollmentKey that the phone needs to 
455
     * retrieve the metadata; you must therefor embed this key in the metadata
456
     * URL that you communicate to the phone.
457
     * 
458
     * @param String $userId The user's id
459
     * @param String $displayName The user's full name
460
     * @param String $sessionId The application's session identifier (defaults to php session)
461
     * @return String The enrollment key
462
     * @throws Exception when start the enrollement session failed
463
     */
464 2
    public function startEnrollmentSession(string $userId, string $displayName, string $sessionId=""): string
465
    {
466 2
        if ($sessionId=="") {
467 1
            $sessionId = session_id();
468
        }
469 2
        $enrollmentKey = $this->_uniqueSessionKey();
470 2
        $data = [
471 2
            "userId" => $userId,
472 2
            "displayName" => $displayName,
473 2
            "sessionId" => $sessionId
474 2
        ];
475 2
        $this->_setStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey, $data, self::ENROLLMENT_EXPIRE);
476 2
        $this->_setEnrollmentStatus($sessionId, self::ENROLLMENT_STATUS_INITIALIZED);
477
478 2
        return $enrollmentKey;
479
    }
480
481
    /**
482
     * Reset an existing enrollment session. (start over)
483
     * @param string $sessionId The application's session identifier (defaults to php session)
484
     * @throws Exception when resetting the session failed
485
     */
486
    public function resetEnrollmentSession(string $sessionId=""): void
487
    {
488
        if ($sessionId=="") {
489
            $sessionId = session_id();
490
        }
491
492
        $this->_setEnrollmentStatus($sessionId, self::ENROLLMENT_STATUS_IDLE);
493
    }
494
495
    /**
496
     * Remove enrollment data based on the enrollment key (which is
497
     * encoded in the enrollment QR code).
498
     *
499
     * @param string $enrollmentKey returned by startEnrollmentSession
500
     * @throws Exception when clearing the enrollment state failed
501
     */
502
    public function clearEnrollmentState(string $enrollmentKey): void
503
    {
504
        $value = $this->_getStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
505
        if (is_array($value) && array_key_exists('sessionId', $value)) {
506
            // Reset the enrollment session (used for polling the status of the enrollment)
507
            $this->resetEnrollmentSession($value['sessionId']);
508
        }
509
        // Remove the enrollment data for a specific enrollment key
510
        $this->_unsetStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
511
    }
512
513
    /**
514
     * Retrieve the enrollment status of an enrollment session.
515
     * 
516
     * @param String $sessionId the application's session identifier 
517
     *                          (defaults to php session)
518
     * @return int Enrollment status.
519
     * @see Tiqr_Service for a definitation of the enrollment status codes
520
     *
521
     * @throws Exception when an error communicating with the state storage backend was detected
522
     */
523 1
    public function getEnrollmentStatus(string $sessionId=""): int
524
    { 
525 1
        if ($sessionId=="") {
526
            $sessionId = session_id(); 
527
        }
528 1
        $status = $this->_getStateValue(self::PREFIX_ENROLLMENT_STATUS, $sessionId);
529 1
        if (is_null($status)) return self::ENROLLMENT_STATUS_IDLE;
530 1
        return $status;
531
    }
532
        
533
    /**
534
     * Generate an enrollment QR code in PNG format and send it to the PHP
535
     * output buffer
536
     *
537
     * You are responsible for sending the "Content-type: image/png" HTTP header, e.g.:
538
     *       header('Content-type: image/png')
539
     *
540
     * @param String $metadataUrl The URL you provide to the phone to retrieve
541
     *                            metadata. This URL must contain the enrollmentKey
542
     *                            provided by startEnrollmentSession (you can choose
543
     *                            the variable name as you are responsible yourself
544
     *                            for retrieving this from the request and passing it
545
     *                            on to the Tiqr server.
546
     * @throws Exception
547
     * @see Tiqr_Service::generateEnrollString()
548
     *
549
     */
550
    public function generateEnrollmentQR(string $metadataUrl): void
551
    { 
552
        $enrollmentString = $this->_getEnrollString($metadataUrl);
553
554
        $this->generateQR($enrollmentString);
555
    }
556
557
    /**
558
     * Generate an enrollment URL
559
     *
560
     * This URL can be used to link directly to the authentication
561
     * application, for example to create a link in a mobile website on the
562
     * same device as where the application is installed
563
     *
564
     * Opening an enrollment url starts the enrollment process in the
565
     * authentication application (e.g. the Tiqr client)
566
     *
567
     * You can encode this URL in a QR code to scan it in the Tiqr app using you own
568
     * QR code library, or use generateQR()
569
     *
570
     * @return string: The enrollment URL
571
     *
572
     * @see Tiqr_Service::generateQR()
573
     */
574 2
    public function generateEnrollString(string $metadataUrl): string
575
    {
576 2
        return $this->_getEnrollString($metadataUrl);
577
    }
578
    
579
    /**
580
     * Retrieve the metadata for an enrollment session.
581
     * 
582
     * When the phone calls the url that you have passed to
583
     * generateEnrollmentQR, you must provide it with the output
584
     * of this function. (Don't forget to json_encode the output.)
585
     * 
586
     * Note, you can call this function only once, as the enrollment session
587
     * data will be destroyed as soon as it is retrieved.
588
     *
589
     * When successful the enrollment status will be set to ENROLLMENT_STATUS_RETRIEVED
590
     *
591
     * @param String $enrollmentKey The enrollmentKey that the phone has posted along with its request.
592
     * @param String $authenticationUrl The url you provide to the phone to post authentication responses
593
     * @param String $enrollmentUrl The url you provide to the phone to post the generated user secret. You must include
594
     *                              a temporary enrollment secret in this URL to make this process secure.
595
     *                              Use getEnrollmentSecret() to get this secret
596
     * @return array An array of metadata that the phone needs to complete
597
     *               enrollment. You must encode it in JSON before you send
598
     *               it to the phone.
599
     * @throws Exception when generating the metadata failed
600
     */
601 1
    public function getEnrollmentMetadata(string $enrollmentKey, string $authenticationUrl, string $enrollmentUrl): array
602
    {
603 1
        $data = $this->_getStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
604 1
        if (!is_array($data)) {
605 1
            $this->logger->error('Unable to find enrollment metadata in state storage');
606 1
            throw new Exception('Unable to find enrollment metadata in state storage');
607
        }
608
609 1
        $metadata = array("service"=>
610 1
                               array("displayName"       => $this->_name,
611 1
                                     "identifier"        => $this->_identifier,
612 1
                                     "logoUrl"           => $this->_logoUrl,
613 1
                                     "infoUrl"           => $this->_infoUrl,
614 1
                                     "authenticationUrl" => $authenticationUrl,
615 1
                                     "ocraSuite"         => $this->_ocraSuite,
616 1
                                     "enrollmentUrl"     => $enrollmentUrl
617 1
                               ),
618 1
                          "identity"=>
619 1
                               array("identifier" =>$data["userId"],
620 1
                                     "displayName"=>$data["displayName"]));
621
622 1
        $this->_unsetStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
623
624 1
        $this->_setEnrollmentStatus($data["sessionId"], self::ENROLLMENT_STATUS_RETRIEVED);
625 1
        return $metadata;
626
    }
627
628
    /** 
629
     * Get a temporary enrollment secret to be able to securely post a user 
630
     * secret.
631
     *
632
     * In the last step of the enrollment process the phone will send the OCRA user secret.
633
     * This is the shared secret is used in the authentication process. To prevent an
634
     * attacker from impersonating a user during enrollment and post a user secret that is known to the attacker,
635
     * a temporary enrollment secret is added to the metadata. This secret must be included in the enrollmentUrl that is
636
     * passed to the getMetadata function so that when the client sends the OCRA user secret to the server this
637
     * enrollment secret is included. The server uses the enrollment secret to authenticate the client, and will
638
     * allow only one submission of a user secret for one enrollment secret.
639
     *
640
     * You MUST use validateEnrollmentSecret() to validate enrollment secret that the client sends before accepting
641
     * the associated OCRA client secret
642
     *
643
     * @param String $enrollmentKey The enrollmentKey generated by startEnrollmentSession() at the start of the
644
     *                              enrollment process.
645
     * @return String The enrollment secret
646
     * @throws Exception when generating the enrollment secret failed
647
     */
648 1
    public function getEnrollmentSecret(string $enrollmentKey): string
649
    {
650 1
         $data = $this->_getStateValue(self::PREFIX_ENROLLMENT, $enrollmentKey);
651 1
         if (!is_array($data)) {
652
             $this->logger->error('getEnrollmentSecret: enrollment key not found');
653
             throw new RuntimeException('enrollment key not found');
654
         }
655 1
         $userId = $data["userId"] ?? NULL;
656 1
         $sessionId = $data["sessionId"] ?? NULL;
657 1
         if (!is_string($userId) || !(is_string($sessionId))) {
658
             throw new RuntimeException('getEnrollmentSecret: invalid enrollment data');
659
         }
660 1
         $enrollmentData = [
661 1
             "userId" => $userId,
662 1
             "sessionId" => $sessionId
663 1
         ];
664 1
         $enrollmentSecret = $this->_uniqueSessionKey();
665 1
         $this->_setStateValue(
666 1
             self::PREFIX_ENROLLMENT_SECRET,
667 1
             $enrollmentSecret,
668 1
             $enrollmentData,
669 1
             self::ENROLLMENT_EXPIRE
670 1
         );
671 1
         return $enrollmentSecret;
672
    }
673
674
    /**
675
     * Validate if an enrollmentSecret that was passed from the phone is valid.
676
     *
677
     * Note: After validating the enrollmentSecret you must call finalizeEnrollment() to
678
     *       invalidate the enrollment secret.
679
     *
680
     * When successful the enrollment state will be set to ENROLLMENT_STATUS_PROCESSED
681
     *
682
     * @param string $enrollmentSecret The enrollmentSecret that the phone posted; it must match
683
     *                                 the enrollmentSecret that was generated using
684
     *                                 getEnrollmentSecret earlier in the process and that the phone
685
     *                                 received as part of the metadata.
686
     *                                 Note that this is not the OCRA user secret that the Phone posts to the server
687
     * @return string The userid of the user that was being enrolled if the enrollment secret is valid. The application
688
     *                should use this userid to store the OCRA user secret that the phone posted.
689
     *
690
     * @throws Exception when the validation failed
691
     */
692 1
    public function validateEnrollmentSecret(string $enrollmentSecret): string
693
    {
694
        try {
695 1
            $data = $this->_getStateValue(self::PREFIX_ENROLLMENT_SECRET, $enrollmentSecret);
696 1
            if (NULL === $data) {
697 1
                throw new RuntimeException('Enrollment secret not found');
698
            }
699 1
            if ( !is_array($data) || !is_string($data["userId"] ?? NULL)) {
700
                throw new RuntimeException('Invalid enrollment data');
701
            }
702
703
            // Secret is valid, application may accept the user secret.
704 1
            $this->_setEnrollmentStatus($data["sessionId"], self::ENROLLMENT_STATUS_PROCESSED);
705 1
            return $data["userId"];
706 1
        } catch (Exception $e) {
707 1
            $this->logger->error('Validation of enrollment secret failed', array('exception' => $e));
708 1
            throw $e;
709
        }
710
    }
711
712
    /**
713
     * Finalize the enrollment process.
714
     *
715
     * Invalidates $enrollmentSecret
716
     *
717
     * Call this after validateEnrollmentSecret
718
     * When successfull the enrollment state will be set to ENROLLMENT_STATUS_FINALIZED
719
     *
720
     * @param String The enrollment secret that was posted by the phone. This is the same secret used in the call to
721
     *               validateEnrollmentSecret()
722
     * @return bool true when finalize was successful, false otherwise
723
     *
724
     * Does not throw
725
     */
726 1
    public function finalizeEnrollment(string $enrollmentSecret): bool
727
    {
728
        try {
729 1
            $data = $this->_getStateValue(self::PREFIX_ENROLLMENT_SECRET, $enrollmentSecret);
730 1
            if (NULL === $data) {
731 1
                throw new RuntimeException('Enrollment secret not found');
732
            }
733 1
            if (is_array($data)) {
734
                // Enrollment is finalized, destroy our session data.
735 1
                $this->_unsetStateValue(self::PREFIX_ENROLLMENT_SECRET, $enrollmentSecret);
736 1
                $this->_setEnrollmentStatus($data["sessionId"], self::ENROLLMENT_STATUS_FINALIZED);
737
            } else {
738
                $this->logger->error(
739
                    'Enrollment status is not finalized, enrollmentsecret was not found in state storage. ' .
740
                    'Warning! the method will still return "true" as a result.'
741
                );
742
            }
743 1
            return true;
744 1
        } catch (Exception $e) {
745
            // Cleanup failed
746 1
            $this->logger->warning('finalizeEnrollment failed', array('exception' => $e));
747
        }
748 1
        return false;
749
    }
750
751
    /**
752
     * Authenticate a user.
753
     * This method should be called when the phone (tiqr client) posts a response to an
754
     * authentication challenge to the server. This method will validate the response and
755
     * returns one of the self::AUTH_RESULT_* codes to indicate success or error
756
     *
757
     * When the authentication was successful the user's session is marked as authenticated.
758
     * This essentially logs the user in. Use getauthenticateduser() and logout() with the
759
     * application's session sessionID to respectively get the authenticated user and clear
760
     * the authentication state.
761
     *
762
     * SECURITY CONSIDERATIONS
763
     *
764
     * Read the included SECURITY.md for important security considerations about:
765
     *
766
     * * PIN guessing: The application must implement protections against response guessing and PIN guessing attacks.
767
     *                 This means that the application must check whether this function returns
768
     *                 AUTH_RESULT_INVALID_RESPONSE and handle that case appropriately.
769
     *
770
     * * Response guessing: When using the default OCRA suite the response is six digits long. This makes it feasible
771
     *                      for an attacker to try all responses in a brute force attack. The application must take
772
     *                      this into account by handing AUTH_RESULT_INVALID_RESPONSE
773
     *
774
     *
775
     * The other error results and exceptions mean that the response could not be validated on the server and should
776
     * therefore not reveal anything useful to the client.
777
     *
778
     * The UserStorage class supports (temporarily) locking a user account. It is the responsibility of the application
779
     * to implement these and other security measures.
780
     *
781
     * @param String $userId The userid of the user that should be authenticated, as sent in the POST back by the tiqr
782
     *                       client. If $userId does not match the optional userId in startAuthenticationSession()
783
     *                       AUTH_RESULT_INVALID_USERID is returned
784
     * @param String $userSecret The OCRA user secret that the application previously stored for $userId using
785
     *                           e.g. a Tiqr_UserSecretStorage
786
     *                           Leave empty when using a OcraService that does not require a user secret
787
     * @param String $sessionKey The authentication session key that was returned by startAuthenticationSession()
788
     *                           If the session key cannot be found in the StateStorage AUTH_RESULT_INVALID_CHALLENGE
789
     *                           is returned
790
     * @param String $response   The response to the challenge that the tiqr client posted back to the server
791
     *
792
     * @return Int The result of the authentication. This is one of the AUTH_RESULT_* constants of the Tiqr_Server class.
793
     * @throws Exception when there was an error during the authentication process
794
     */
795 1
    public function authenticate(string $userId, string $userSecret, string $sessionKey, string $response): int
796
    {
797
        try {
798 1
            $state = $this->_getStateValue(self::PREFIX_CHALLENGE, $sessionKey);
799 1
            if (is_null($state)) {
800 1
                $this->logger->notice('The auth challenge could not be found in the state storage');
801 1
                return self::AUTH_RESULT_INVALID_CHALLENGE;
802
            }
803
        } catch (Exception $e) {
804
            $this->logger->error('Error looking up challenge in state storage', array('exception' => $e));
805
            throw $e;
806
        }
807
808 1
        $sessionId = $state["sessionId"] ?? NULL;   // Application's sessionId
809 1
        $challenge = $state["challenge"] ?? NULL;   // The challenge we sent to the Tiqr client
810 1
        if (!is_string($sessionId) || (!is_string($challenge)) ) {
811
            throw new RuntimeException('Invalid state for state storage');
812
        }
813
814
        // The user ID is optional, it is set when the application requested authentication of a specific userId
815
        // instead of letting the client decide
816 1
        $challengeUserId = $state["userId"] ?? NULL;
817
818
        // If the application requested a specific userId, verify that that is that userId that we're now authenticating
819 1
        if ($challengeUserId!==NULL && ($userId !== $challengeUserId)) {
820 1
            $this->logger->error(
821 1
                sprintf('Authentication failed: the requested userId "%s" does not match userId "%s" that is being authenticated',
822 1
                $challengeUserId, $userId)
823 1
            );
824 1
            return self::AUTH_RESULT_INVALID_USERID; // requested and actual userId do not match
825
        }
826
827
        try {
828 1
            $equal = $this->_ocraService->verifyResponse($response, $userId, $userSecret, $challenge, $sessionKey);
829
        } catch (Exception $e) {
830
            $this->logger->error(sprintf('Error verifying OCRA response for user "%s"', $userId), array('exception' => $e));
831
            throw $e;
832
        }
833
834 1
        if ($equal) {
835
            // Set application session as authenticated
836 1
            $this->_setStateValue(self::PREFIX_AUTHENTICATED, $sessionId, $userId, self::LOGIN_EXPIRE);
837 1
            $this->logger->notice(sprintf('Authenticated user "%s" in session "%s"', $userId, $sessionId));
838
839
            // Cleanup challenge
840
            // Future authentication attempts with this sessionKey will get a AUTH_RESULT_INVALID_CHALLENGE
841
            // This QR code / push notification cannot be used again
842
            // Cleaning up only after successful authentication enables the user to retry authentication after e.g. an
843
            // invalid response
844
            try {
845 1
                $this->_unsetStateValue(self::PREFIX_CHALLENGE, $sessionKey); // May throw
846
            } catch (Exception $e) {
847
                // Only log error
848
                $this->logger->warning('Could not delete authentication session key', array('error' => $e));
849
            }
850
851 1
            return self::AUTH_RESULT_AUTHENTICATED;
852
        }
853 1
        $this->logger->error('Authentication failed: invalid response');
854 1
        return self::AUTH_RESULT_INVALID_RESPONSE;
855
    }
856
857
    /**
858
     * Log the user out.
859
     * It is not an error is the $sessionId does not exists, or when the $sessionId has expired
860
     *
861
     * @param String $sessionId The application's session identifier (defaults
862
     *                          to the php session).
863
     *                          This is the application's sessionId that was provided to startAuthenticationSession()
864
     *
865
     * @throws Exception when there was an error communicating with the storage backed
866
     */
867 1
    public function logout(string $sessionId=""): void
868
    {
869 1
        if ($sessionId=="") {
870
            $sessionId = session_id(); 
871
        }
872
        
873 1
        $this->_unsetStateValue(self::PREFIX_AUTHENTICATED, $sessionId);
874
    }
875
    
876
    /**
877
     * Exchange a notificationToken for a deviceToken.
878
     * 
879
     * During enrollment, the phone will post a notificationAddress that can be 
880
     * used to send notifications. To actually send the notification, 
881
     * this address should be converted to the real device address.
882
     *
883
     * @param String $notificationType    The notification type.
884
     * @param String $notificationAddress The address that was stored during enrollment.
885
     *
886
     * @return String|bool The device address that can be used to send a notification.
887
     *                     false on error
888
     */
889
    public function translateNotificationAddress(string $notificationType, string $notificationAddress)
890
    {
891
        if ($notificationType == 'APNS' || $notificationType == 'FCM' || $notificationType == 'GCM') {
892
            return $this->_deviceStorage->getDeviceToken($notificationAddress);
893
        } else {
894
            return $notificationAddress;
895
        }
896
    }
897
    
898
    /**
899
     * Retrieve the currently logged in user.
900
     * @param String $sessionId The application's session identifier (defaults
901
     *                          to the php session).
902
     *                          This is the application's sessionId that was provided to startAuthenticationSession()
903
     * @return string|NULL The userId of the authenticated user,
904
     *                     NULL if no user is logged in
905
     *                     NULL if the user's login state could not be determined
906
     *
907
     * Does not throw
908
     */
909 1
    public function getAuthenticatedUser(string $sessionId=""): ?string
910
    {
911 1
        if ($sessionId=="") {
912
            $this->logger->debug('Using the PHP session id, as no session id was provided');
913
            $sessionId = session_id(); 
914
        }
915
        
916
        try {
917 1
            return $this->_getStateValue("authenticated_", $sessionId);
918
        }
919
        catch (Exception $e) {
920
            $this->logger->error('getAuthenticatedUser failed', array('exception'=>$e));
921
            return NULL;
922
        }
923
    }
924
    
925
    /**
926
     * Generate a authentication challenge URL
927
     * @param String $sessionKey The authentication sessionKey
928
     *
929
     * @return string AuthenticationURL
930
     * @throws Exception
931
     */
932 3
    protected function _getChallengeUrl(string $sessionKey): string
933
    {
934
        // Lookup the authentication session data and use this to generate the application specific
935
        // authentication URL
936
        // The are two formats see: https://tiqr.org/technical/protocol/
937
        // We probably just generated the challenge and stored it in the StateStorage
938
        // We can save a roundtrip to the storage backend here by reusing this information
939
940 3
        $state = $this->_getStateValue(self::PREFIX_CHALLENGE, $sessionKey);
941 3
        if (is_null($state)) {
942
            $this->logger->error(
943
                sprintf(
944
                'Cannot get session key "%s"',
945
                    $sessionKey
946
                )
947
            );
948
            throw new Exception('Cannot find sessionkey');
949
        }
950
951 3
        $userId = $state["userId"] ?? NULL;
952 3
        $challenge = $state["challenge"] ?? '';
953 3
        $spIdentifier = $state["spIdentifier"] ?? '';
954
955 3
        if ( (strpos($this->_protocolAuth, 'https://') === 0) || (strpos($this->_protocolAuth, 'http://') === 0) ) {
956
            // Create universal Link
957 2
            $parameters=array();
958 2
            if (!is_null($userId)) {
959 1
                $parameters[]='u='.urlencode($userId);
960
            }
961 2
            $parameters[]='s='.urlencode($sessionKey);
962 2
            $parameters[]='q='.urlencode($challenge);
963 2
            $parameters[]='i='.urlencode($this->getIdentifier());
964 2
            $parameters[]='v='.urlencode($this->_protocolVersion);
965 2
            return $this->_protocolAuth.'?'.implode('&', $parameters);
966
        }
967
968
        // Create custom URL scheme
969
        // Last bit is the spIdentifier
970 1
        return $this->_protocolAuth."://".(!is_null($userId)?urlencode($userId).'@':'').$this->getIdentifier()."/".$sessionKey."/".$challenge."/".urlencode($spIdentifier)."/".$this->_protocolVersion;
971
    }
972
973
    /**
974
     * Generate an enrollment string
975
     * @param String $metadataUrl The URL you provide to the phone to retrieve metadata.
976
     */
977 2
    protected function _getEnrollString(string $metadataUrl): string
978
    {
979
        // The are two formats see: https://tiqr.org/technical/protocol/
980
981 2
        if ( (strpos($this->_protocolEnroll, 'https://') === 0) || (strpos($this->_protocolEnroll, 'http://') === 0) ) {
982
            // Create universal Link
983 1
            return $this->_protocolEnroll.'?metadata='.urlencode($metadataUrl);
984
        }
985
986
        // Create custom URL scheme
987 1
        return $this->_protocolEnroll."://".$metadataUrl;
988
    }
989
990
    /**
991
     * Generate a unique secure pseudo-random value to be used as session key in the
992
     * tiqr protocol. These keys are sent to the tiqr client during enrollment and authentication
993
     * And are used in the server as part of key for data in StateStorage
994
     * @return String The session key as HEX encoded string
995
     * @throws Exception When the key could not be generated
996
     */
997 5
    protected function _uniqueSessionKey(): string
998
    {
999
1000 5
        return bin2hex( Tiqr_Random::randomBytes(self::SESSION_KEY_LENGTH_BYTES) );
1001
    }
1002
    
1003
    /**
1004
     * Internal function to set the enrollment status of a session.
1005
     * @param String $sessionId The sessionId to set the status for
1006
     * @param int $status The new enrollment status (one of the 
1007
     *                    self::ENROLLMENT_STATUS_* constants)
1008
     * @throws Exception when updating the status fails
1009
     */
1010 2
    protected function _setEnrollmentStatus(string $sessionId, int $status): void
1011
    {
1012 2
        if (($status < 1) || ($status > 6)) {
1013
            // Must be one of the self::ENROLLMENT_STATUS_* constants
1014
            throw new InvalidArgumentException('Invalid enrollment status');
1015
        }
1016 2
        $this->_setStateValue(self::PREFIX_ENROLLMENT_STATUS, $sessionId, $status, self::ENROLLMENT_EXPIRE);
1017
    }
1018
1019
    /** Store a value in StateStorage
1020
     * @param string $key_prefix
1021
     * @param string $key
1022
     * @param mixed $value
1023
     * @param int $expire
1024
     * @return void
1025
     * @throws Exception
1026
     *
1027
     * @see Tiqr_StateStorage_StateStorageInterface::setValue()
1028
     */
1029 5
    protected function _setStateValue(string $key_prefix, string $key, $value, int $expire): void {
1030 5
        $this->_stateStorage->setValue(
1031 5
            $key_prefix . $this->_hashKey($key),
1032 5
            $value,
1033 5
            $expire
1034 5
        );
1035
    }
1036
1037
    /** Get a value from StateStorage
1038
     * @param string $key_prefix
1039
     * @param string $key
1040
     * @return mixed
1041
     * @throws Exception
1042
     *
1043
     * @see Tiqr_StateStorage_StateStorageInterface::getValue()
1044
     */
1045
1046 4
    protected function _getStateValue(string $key_prefix, string $key) {
1047 4
        return $this->_stateStorage->getValue(
1048 4
            $key_prefix . $this->_hashKey($key)
1049 4
        );
1050
    }
1051
1052
    /** Remove a key and its value from StateStorage
1053
     * @param string $key_prefix
1054
     * @param string $key
1055
     * @return void
1056
     * @throws Exception
1057
     *
1058
     * @see Tiqr_StateStorage_StateStorageInterface::unsetValue()
1059
     */
1060 2
    protected function _unsetStateValue(string $key_prefix, string $key): void {
1061 2
        $this->_stateStorage->unsetValue(
1062 2
            $key_prefix . $this->_hashKey($key)
1063 2
        );
1064
    }
1065
1066
    /**
1067
     * Create a stable hash of a $key. Used to improve the security of stored keys
1068
     * @param string $key
1069
     * @return string hashed $key
1070
     */
1071 5
    protected function _hashKey(string $key): string
1072
    {
1073 5
        return hash_hmac('sha256', $key, $this->_stateStorageSalt);
1074
    }
1075
}
1076