From 9735116635f2394273b716db0c5165a5ca09037e Mon Sep 17 00:00:00 2001 From: RobThree Date: Fri, 17 Feb 2017 14:53:55 +0100 Subject: [PATCH] * Implemented ITimeProviders --- README.md | 11 ++- TwoFactorAuth.phpproj | 7 ++ composer.json | 2 +- lib/Providers/Qr/GoogleQRCodeProvider.php | 2 +- lib/Providers/Qr/QRServerProvider.php | 2 +- lib/Providers/Qr/QRicketProvider.php | 2 +- lib/Providers/Rng/HashRNGProvider.php | 2 +- lib/Providers/Rng/MCryptRNGProvider.php | 2 +- lib/Providers/Rng/OpenSSLRNGProvider.php | 4 +- .../ConvertUnixTimeDotComTimeProvider.php | 15 ++++ lib/Providers/Time/HttpTimeProvider.php | 51 +++++++++++ lib/Providers/Time/ITimeProvider.php | 8 ++ .../Time/LocalMachineTimeProvider.php | 9 ++ lib/Providers/Time/TimeException.php | 5 ++ lib/TwoFactorAuth.php | 50 ++++++++++- tests/TwoFactorAuthTest.php | 87 +++++++++++++++---- 16 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 lib/Providers/Time/ConvertUnixTimeDotComTimeProvider.php create mode 100644 lib/Providers/Time/HttpTimeProvider.php create mode 100644 lib/Providers/Time/ITimeProvider.php create mode 100644 lib/Providers/Time/LocalMachineTimeProvider.php create mode 100644 lib/Providers/Time/TimeException.php diff --git a/README.md b/README.md index 7028279..0fba429 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Here are some code snippets that should help you get started... $tfa = new RobThree\Auth\TwoFactorAuth('My Company'); ```` -The TwoFactorAuth class constructor accepts 6 parameters (all optional): +The TwoFactorAuth class constructor accepts 7 parameters (all optional): Parameter | Default value | Use ------------------|---------------|-------------------------------------------------- @@ -43,6 +43,7 @@ Parameter | Default value | Use `$algorithm` | `sha1` | The algorithm used `$qrcodeprovider` | `null` | QR-code provider (more on this later) `$rngprovider` | `null` | Random Number Generator provider (more on this later) +`$timeprovider` | `null` | Time provider (more on this later) These parameters are all '`write once`'; the class will, for it's lifetime, use these values when generating / calculating codes. The number of digits, the period and algorithm are all set to values Google's Authticator app uses (and supports). You may specify `8` digits, a period of `45` seconds and the `sha256` algorithm but the authenticator app (be it Google's implementation, Authy or any other app) may or may not support these values. Your mileage may vary; keep it on the safe side if you don't control which app your audience uses. @@ -177,6 +178,14 @@ This library also comes with three 'built-in' RNG providers ([Random Number Gene You can easily implement your own `RNGProvider` by simply implementing the `IRNGProvider` interface. Each of the 'built-in' RNG providers have some constructor parameters that allow you to 'tweak' some of the settings to use when creating the random bytes such as which source to use (`MCryptRNGProvider`) or which hashing algorithm (`HashRNGProvider`). I encourage you to have a look at some of the ['built-in' RNG providers](lib/Providers/Rng) for details and the [`IRNGProvider` interface](lib/Providers/Rng/IRNGProvider.php). +### Time providers + +Another set of providers in this library are the Time Providers; this library provides three 'built-in' ones. The default Time Provider used is the [`LocalMachineTimeProvider`](lib/Providers/Time/LocalMachineTimeProvider.php); this provider simply returns the output of `Time()` and is *highly recommended* as default provider. The [`HttpTimeProvider`](lib/Providers/Time/HttpTimeProvider.php) executes a `HEAD` request against a given webserver (default: google.com) and tries to extract the `Date:`-HTTP header and returns it's date. Other url's/domains can be used by specifying the url in the constructor. The final Time Provider is the [`ConvertUnixTimeDotComTimeProvider`](lib/Providers/Time/ConvertUnixTimeDotComTimeProvider.php) which does a HTTP request to `convert-unix-time.com/api` and decodes the `JSON` result to retrieve the time. + +You can easily implement your own `TimeProvider` by simply implementing the `ITimeProvider` interface. + +As to *why* these Time Providers are implemented: it allows the TwoFactorAuth library to ensure the hosts time is correct (or rather: within a margin). You can use the `ensureCorrectTime()` method to ensure the hosts time is correct. By default this method will compare the hosts time (returned by calling `time()` on the `LocalMachineTimeProvider`) to Google's and convert-unix-time.com's current time. You can pass an array of `ITimeProvider`s and specify the `leniency` (second argument) allowed (default: 5 seconds). The method will throw when the TwoFactorAuth's timeprovider (which can be any `ITimeProvider`, see constructor) differs more than the given amount of seconds from any of the given `ITimeProviders`. We advise to call this method sparingly when relying on 3rd parties (which both the `HttpTimeProvider` and `ConvertUnixTimeDotComTimeProvider` do) or, if you need to ensure time is correct on a (very) regular basis to implement an `ITimeProvider` that is more efficient than the 'built-in' ones (like use a GPS signal). The `ensureCorrectTime()` method is mostly to be used to make sure the server is configured correctly. + ## Integrations - [CakePHP 3](https://github.com/andrej-griniuk/cakephp-two-factor-auth) diff --git a/TwoFactorAuth.phpproj b/TwoFactorAuth.phpproj index 8300517..7fa2a58 100644 --- a/TwoFactorAuth.phpproj +++ b/TwoFactorAuth.phpproj @@ -38,6 +38,11 @@ + + + + + @@ -47,6 +52,7 @@ + @@ -55,6 +61,7 @@ + diff --git a/composer.json b/composer.json index cd93eab..a4c1375 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "robthree/twofactorauth", "description": "Two Factor Authentication", - "version": "1.5.2", + "version": "1.6", "type": "library", "keywords": [ "Authentication", "Two Factor Authentication", "Multi Factor Authentication", "TFA", "MFA", "PHP", "Authenticator", "Authy" ], "homepage": "https://github.com/RobThree/TwoFactorAuth", diff --git a/lib/Providers/Qr/GoogleQRCodeProvider.php b/lib/Providers/Qr/GoogleQRCodeProvider.php index 1fe65a0..19e086b 100644 --- a/lib/Providers/Qr/GoogleQRCodeProvider.php +++ b/lib/Providers/Qr/GoogleQRCodeProvider.php @@ -11,7 +11,7 @@ class GoogleQRCodeProvider extends BaseHTTPQRCodeProvider function __construct($verifyssl = false, $errorcorrectionlevel = 'L', $margin = 1) { if (!is_bool($verifyssl)) - throw new QRException('VerifySSL must be bool'); + throw new \QRException('VerifySSL must be bool'); $this->verifyssl = $verifyssl; diff --git a/lib/Providers/Qr/QRServerProvider.php b/lib/Providers/Qr/QRServerProvider.php index 2d889f9..928b87b 100644 --- a/lib/Providers/Qr/QRServerProvider.php +++ b/lib/Providers/Qr/QRServerProvider.php @@ -43,7 +43,7 @@ class QRServerProvider extends BaseHTTPQRCodeProvider case 'eps': return 'application/postscript'; } - throw new QRException(sprintf('Unknown MIME-type: %s', $this->format)); + throw new \QRException(sprintf('Unknown MIME-type: %s', $this->format)); } public function getQRCodeImage($qrtext, $size) diff --git a/lib/Providers/Qr/QRicketProvider.php b/lib/Providers/Qr/QRicketProvider.php index e2f404b..59e27cc 100644 --- a/lib/Providers/Qr/QRicketProvider.php +++ b/lib/Providers/Qr/QRicketProvider.php @@ -33,7 +33,7 @@ class QRicketProvider extends BaseHTTPQRCodeProvider case 'j': return 'image/jpeg'; } - throw new QRException(sprintf('Unknown MIME-type: %s', $this->format)); + throw new \QRException(sprintf('Unknown MIME-type: %s', $this->format)); } public function getQRCodeImage($qrtext, $size) diff --git a/lib/Providers/Rng/HashRNGProvider.php b/lib/Providers/Rng/HashRNGProvider.php index fe7a8b7..ca6e859 100644 --- a/lib/Providers/Rng/HashRNGProvider.php +++ b/lib/Providers/Rng/HashRNGProvider.php @@ -8,7 +8,7 @@ class HashRNGProvider implements IRNGProvider function __construct($algorithm = 'sha256' ) { $algos = array_values(hash_algos()); if (!in_array($algorithm, $algos, true)) - throw new RNGException('Unsupported algorithm specified'); + throw new \RNGException('Unsupported algorithm specified'); $this->algorithm = $algorithm; } diff --git a/lib/Providers/Rng/MCryptRNGProvider.php b/lib/Providers/Rng/MCryptRNGProvider.php index 493ccf3..1f55fa3 100644 --- a/lib/Providers/Rng/MCryptRNGProvider.php +++ b/lib/Providers/Rng/MCryptRNGProvider.php @@ -13,7 +13,7 @@ class MCryptRNGProvider implements IRNGProvider public function getRandomBytes($bytecount) { $result = mcrypt_create_iv($bytecount, $this->source); if ($result === false) - throw new RNGException('mcrypt_create_iv returned an invalid value'); + throw new \RNGException('mcrypt_create_iv returned an invalid value'); return $result; } diff --git a/lib/Providers/Rng/OpenSSLRNGProvider.php b/lib/Providers/Rng/OpenSSLRNGProvider.php index b1931bf..dc66c64 100644 --- a/lib/Providers/Rng/OpenSSLRNGProvider.php +++ b/lib/Providers/Rng/OpenSSLRNGProvider.php @@ -13,9 +13,9 @@ class OpenSSLRNGProvider implements IRNGProvider public function getRandomBytes($bytecount) { $result = openssl_random_pseudo_bytes($bytecount, $crypto_strong); if ($this->requirestrong && ($crypto_strong === false)) - throw new RNGException('openssl_random_pseudo_bytes returned non-cryptographically strong value'); + throw new \RNGException('openssl_random_pseudo_bytes returned non-cryptographically strong value'); if ($result === false) - throw new RNGException('openssl_random_pseudo_bytes returned an invalid value'); + throw new \RNGException('openssl_random_pseudo_bytes returned an invalid value'); return $result; } diff --git a/lib/Providers/Time/ConvertUnixTimeDotComTimeProvider.php b/lib/Providers/Time/ConvertUnixTimeDotComTimeProvider.php new file mode 100644 index 0000000..4939f0d --- /dev/null +++ b/lib/Providers/Time/ConvertUnixTimeDotComTimeProvider.php @@ -0,0 +1,15 @@ +timestamp)) + throw new \TimeException('Unable to retrieve time from convert-unix-time.com'); + return $json->timestamp; + } +} \ No newline at end of file diff --git a/lib/Providers/Time/HttpTimeProvider.php b/lib/Providers/Time/HttpTimeProvider.php new file mode 100644 index 0000000..db75db0 --- /dev/null +++ b/lib/Providers/Time/HttpTimeProvider.php @@ -0,0 +1,51 @@ +url = $url; + } + + public function getTime() { + try { + $host = parse_url($this->url, PHP_URL_HOST); + $context = stream_context_create([ + 'http' => [ + 'method' => 'HEAD', + 'follow_location' => false, + 'ignore_errors' => true, + 'max_redirects' => 0, + 'request_fulluri' => true, + 'header' => [ + 'Connection: close', + 'User-agent: TwoFactorAuth HttpTimeProvider (https://github.com/RobThree/TwoFactorAuth)' + ] + ], + 'ssl' => [ + 'SNI_enabled' => true, + 'SNI_server_name' => $host + ] + ]); + $fd = fopen($this->url, 'rb', false, $context); + $headers = stream_get_meta_data($fd)['wrapper_data']; + fclose($fd); + + foreach ($headers as $h) { + if (strcasecmp(substr($h, 0, 5), 'Date:') === 0) + return \DateTime::createFromFormat('D, d M Y H:i:s O+', trim(substr($h,5)))->getTimestamp(); + } + throw new \TimeException(sprintf('Unable to retrieve time from %s (Invalid or no "Date:" header found)', $this->url)); + } + catch (Exception $ex) { + throw new \TimeException(sprintf('Unable to retrieve time from %s (%s)', $this->url, $ex->getMessage())); + } + } +} \ No newline at end of file diff --git a/lib/Providers/Time/ITimeProvider.php b/lib/Providers/Time/ITimeProvider.php new file mode 100644 index 0000000..a3b87a2 --- /dev/null +++ b/lib/Providers/Time/ITimeProvider.php @@ -0,0 +1,8 @@ +issuer = $issuer; if (!is_int($digits) || $digits <= 0) @@ -36,6 +38,7 @@ class TwoFactorAuth $this->algorithm = $algorithm; $this->qrcodeprovider = $qrcodeprovider; $this->rngprovider = $rngprovider; + $this->timeprovider = $timeprovider; self::$_base32 = str_split(self::$_base32dict); self::$_base32lookup = array_flip(self::$_base32); @@ -121,9 +124,37 @@ class TwoFactorAuth . base64_encode($qrcodeprovider->getQRCodeImage($this->getQRText($label, $secret), $size)); } + /** + * Compare default timeprovider with specified timeproviders and ensure the time is within the specified number of seconds (leniency) + */ + public function ensureCorrectTime(array $timeproviders = null, $leniency = 5) + { + if ($timeproviders != null && !is_array($timeproviders)) + throw new TwoFactorAuthException('No timeproviders specified'); + + if ($timeproviders == null) + $timeproviders = array( + new Providers\Time\ConvertUnixTimeDotComTimeProvider(), + new Providers\Time\HttpTimeProvider() + ); + + // Get default time provider + $timeprovider = $this->getTimeProvider(); + + // Iterate specified time providers + foreach ($timeproviders as $t) { + if (!($t instanceof ITimeProvider)) + throw new TwoFactorAuthException('Object does not implement ITimeProvider'); + + // Get time from default time provider and compare to specific time provider and throw if time difference is more than specified number of seconds leniency + if (abs($timeprovider->getTime() - $t->getTime()) > $leniency) + throw new TwoFactorAuthException(sprintf('Time for timeprovider is off by more than %d seconds when compared to %s', $leniency, get_class($t))); + } + } + private function getTime($time) { - return ($time === null) ? time() : $time; + return ($time === null) ? $this->timeprovider->getTime() : $time; } private function getTimeSlice($time = null, $offset = 0) @@ -165,6 +196,7 @@ class TwoFactorAuth $output .= chr(bindec(str_pad($block, 8, 0, STR_PAD_RIGHT))); return $output; } + /** * @return IQRCodeProvider * @throws TwoFactorAuthException @@ -177,6 +209,7 @@ class TwoFactorAuth } return $this->qrcodeprovider; } + /** * @return IRNGProvider * @throws TwoFactorAuthException @@ -200,4 +233,17 @@ class TwoFactorAuth } throw new TwoFactorAuthException('Unable to find a suited RNGProvider'); } + + /** + * @return ITimeProvider + * @throws TwoFactorAuthException + */ + public function getTimeProvider() + { + // Set default time provider if none was specified + if (null === $this->timeprovider) { + return $this->timeprovider = new Providers\Time\LocalMachineTimeProvider(); + } + return $this->timeprovider; + } } \ No newline at end of file diff --git a/tests/TwoFactorAuthTest.php b/tests/TwoFactorAuthTest.php index c631d04..b81ca57 100644 --- a/tests/TwoFactorAuthTest.php +++ b/tests/TwoFactorAuthTest.php @@ -5,6 +5,7 @@ require_once 'lib/TwoFactorAuthException.php'; require_once 'lib/Providers/Qr/IQRCodeProvider.php'; require_once 'lib/Providers/Qr/BaseHTTPQRCodeProvider.php'; require_once 'lib/Providers/Qr/GoogleQRCodeProvider.php'; +require_once 'lib/Providers/Qr/QRException.php'; require_once 'lib/Providers/Rng/IRNGProvider.php'; require_once 'lib/Providers/Rng/RNGException.php'; @@ -12,10 +13,18 @@ require_once 'lib/Providers/Rng/CSRNGProvider.php'; require_once 'lib/Providers/Rng/MCryptRNGProvider.php'; require_once 'lib/Providers/Rng/OpenSSLRNGProvider.php'; require_once 'lib/Providers/Rng/HashRNGProvider.php'; +require_once 'lib/Providers/Rng/RNGException.php'; + +require_once 'lib/Providers/Time/ITimeProvider.php'; +require_once 'lib/Providers/Time/LocalMachineTimeProvider.php'; +require_once 'lib/Providers/Time/HttpTimeProvider.php'; +require_once 'lib/Providers/Time/ConvertUnixTimeDotComTimeProvider.php'; +require_once 'lib/Providers/Time/TimeException.php'; use RobThree\Auth\TwoFactorAuth; use RobThree\Auth\Providers\Qr\IQRCodeProvider; use RobThree\Auth\Providers\Rng\IRNGProvider; +use RobThree\Auth\Providers\Time\ITimeProvider; class TwoFactorAuthTest extends PHPUnit_Framework_TestCase @@ -87,6 +96,40 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase $this->assertEquals('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567A', $tfa->createSecret(321)); } + public function testEnsureCorrectTimeDoesNotThrowForCorrectTime() { + $tpr1 = new TestTimeProvider(123); + $tpr2 = new TestTimeProvider(128); + + $tfa = new TwoFactorAuth('Test', 6, 30, 'sha1', null, null, $tpr1); + $tfa->ensureCorrectTime([$tpr2]); // 128 - 123 = 5 => within default leniency + } + + /** + * @expectedException \RobThree\Auth\TwoFactorAuthException + */ + public function testEnsureCorrectTimeThrowsOnIncorrectTime() { + $tpr1 = new TestTimeProvider(123); + $tpr2 = new TestTimeProvider(124); + + $tfa = new TwoFactorAuth('Test', 6, 30, 'sha1', null, null, $tpr1); + $tfa->ensureCorrectTime([$tpr2], 0); // We force a leniency of 0, 124-123 = 1 so this should throw + } + + + public function testEnsureDefaultTimeProviderReturnsCorrectTime() { + $tfa = new TwoFactorAuth('Test', 6, 30, 'sha1'); + $tfa->ensureCorrectTime([new TestTimeProvider(time())], 1); // Use a leniency of 1, should the time change between both time() calls + } + + public function testEnsureAllTimeProvidersReturnCorrectTime() { + $tfa = new TwoFactorAuth('Test', 6, 30, 'sha1'); + $tfa->ensureCorrectTime([ + new RobThree\Auth\Providers\Time\ConvertUnixTimeDotComTimeProvider(), + new RobThree\Auth\Providers\Time\HttpTimeProvider(), // Uses google.com by default + new RobThree\Auth\Providers\Time\HttpTimeProvider('https://github.com'), + new RobThree\Auth\Providers\Time\HttpTimeProvider('https://yahoo.com'), + ]); + } public function testVerifyCodeWorksCorrectly() { @@ -122,7 +165,7 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase */ public function testGetQRCodeImageAsDataUriThrowsOnInvalidSize() { $qr = new TestQrProvider(); - + $tfa = new TwoFactorAuth('Test', 6, 30, 'sha1', $qr); $tfa->getQRCodeImageAsDataUri('Test', 'VMR466AB62ZBOKHE', 0); } @@ -134,7 +177,7 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase $tfa = new TwoFactorAuth('Test'); $tfa->getCode('FOO1BAR8BAZ9'); //1, 8 & 9 are invalid chars } - + /** * @expectedException \RobThree\Auth\TwoFactorAuthException */ @@ -142,7 +185,7 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase $tfa = new TwoFactorAuth('Test'); $tfa->getCode('mzxw6==='); //Lowercase } - + public function testKnownBase32DecodeTestVectors() { // We usually don't test internals (e.g. privates) but since we rely heavily on base32 decoding and don't want // to expose this method nor do we want to give people the possibility of implementing / providing their own base32 @@ -151,15 +194,15 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase // be any bugs hiding in there. We **could** 'fool' ourselves by calling the public getCode() method (which uses // base32decode internally) and then make sure getCode's output (in digits) equals expected output since that would // mean the base32Decode() works as expected but that **could** hide some subtle bug(s) in decoding the base32 string. - + // "In general, you don't want to break any encapsulation for the sake of testing (or as Mom used to say, "don't // expose your privates!"). Most of the time, you should be able to test a class by exercising its public methods." // Dave Thomas and Andy Hunt -- "Pragmatic Unit Testing $tfa = new TwoFactorAuth('Test'); - + $method = new ReflectionMethod('RobThree\Auth\TwoFactorAuth', 'base32Decode'); $method->setAccessible(true); - + // Test vectors from: https://tools.ietf.org/html/rfc4648#page-12 $this->assertEquals('', $method->invoke($tfa, '')); $this->assertEquals('f', $method->invoke($tfa, 'MY======')); @@ -169,17 +212,17 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase $this->assertEquals('fooba', $method->invoke($tfa, 'MZXW6YTB')); $this->assertEquals('foobar', $method->invoke($tfa, 'MZXW6YTBOI======')); } - + public function testKnownBase32DecodeUnpaddedTestVectors() { // See testKnownBase32DecodeTestVectors() for the rationale behind testing the private base32Decode() method. // This test ensures that strings without the padding-char ('=') are also decoded correctly. - // https://tools.ietf.org/html/rfc4648#page-4: + // https://tools.ietf.org/html/rfc4648#page-4: // "In some circumstances, the use of padding ("=") in base-encoded data is not required or used." $tfa = new TwoFactorAuth('Test'); - + $method = new ReflectionMethod('RobThree\Auth\TwoFactorAuth', 'base32Decode'); $method->setAccessible(true); - + // Test vectors from: https://tools.ietf.org/html/rfc4648#page-12 $this->assertEquals('', $method->invoke($tfa, '')); $this->assertEquals('f', $method->invoke($tfa, 'MY')); @@ -202,7 +245,7 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase $this->assertEquals('69279037', $tfa->getCode($secret, 2000000000)); $this->assertEquals('65353130', $tfa->getCode($secret, 20000000000)); } - + public function testKnownTestVectors_sha256() { //Known test vectors for SHA256: https://tools.ietf.org/html/rfc6238#page-15 $secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA'; //== base32encode('12345678901234567890123456789012') @@ -214,7 +257,7 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase $this->assertEquals('90698825', $tfa->getCode($secret, 2000000000)); $this->assertEquals('77737706', $tfa->getCode($secret, 20000000000)); } - + public function testKnownTestVectors_sha512() { //Known test vectors for SHA512: https://tools.ietf.org/html/rfc6238#page-15 $secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA'; //== base32encode('1234567890123456789012345678901234567890123456789012345678901234') @@ -247,7 +290,7 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase $this->assertEquals($l, strlen($rng->getRandomBytes($l))); $this->assertEquals(false, $rng->isCryptographicallySecure()); } - + /** * @requires function mcrypt_create_iv */ @@ -297,11 +340,11 @@ class TwoFactorAuthTest extends PHPUnit_Framework_TestCase class TestRNGProvider implements IRNGProvider { private $isSecure; - + function __construct($isSecure = false) { $this->isSecure = $isSecure; } - + public function getRandomBytes($bytecount) { $result = ''; for ($i=0; $i<$bytecount; $i++) @@ -309,7 +352,7 @@ class TestRNGProvider implements IRNGProvider { return $result; } - + public function isCryptographicallySecure() { return $this->isSecure; } @@ -323,4 +366,16 @@ class TestQrProvider implements IQRCodeProvider { public function getMimeType() { return 'test/test'; } +} + +class TestTimeProvider implements ITimeProvider { + private $time; + + function __construct($time) { + $this->time = $time; + } + + public function getTime() { + return $this->time; + } } \ No newline at end of file