Passed
Branch feature/3.0 (886f3a)
by Pieter van der
03:50
created

Tiqr_Service::__construct()   B

Complexity

Conditions 6
Paths 7

Size

Total Lines 43
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 6.1893

Importance

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