Passed
Push — master ( 35fb8a...2dccb2 )
by Pieter van der
05:32 queued 14s
created

Tiqr_Service::_setEnrollmentStatus()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 3.1406

Importance

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