1<?php
2
3// The MIT License (MIT)
4
5// Copyright (c) 2013 Chris Cornutt
6
7// Permission is hereby granted, free of charge, to any person obtaining a copy
8// of this software and associated documentation files (the "Software"), to deal
9// in the Software without restriction, including without limitation the rights
10// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11// copies of the Software, and to permit persons to whom the Software is
12// furnished to do so, subject to the following conditions:
13
14// The above copyright notice and this permission notice shall be included in
15// all copies or substantial portions of the Software.
16
17// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23// THE SOFTWARE.
24
25namespace GAuth;
26
27/**
28 * A class for generating the codes compatible with the Google Authenticator
29 * clients.
30 *
31 * NOTE: A lot of the logic from this class has been borrowed from this class:
32 * http://www.idontplaydarts.com/wp-content/uploads/2011/07/ga.php_.txt
33 *
34 * @author Chris Cornutt <ccornutt@phpdeveloper.org>
35 * @package GAuth
36 * @license MIT
37 */
38
39class Auth
40{
41	/**
42	 * Internal lookup table
43	 * @var array
44	 */
45	private $lookup = array();
46
47	/**
48	 * Initialization key
49	 * @var string
50	 */
51	private $initKey = null;
52
53	/**
54	 * Seconds between key refreshes
55	 * @var integer
56	 */
57	private $refreshSeconds = 30;
58
59	/**
60	 * Length of codes to generate
61	 * @var integer
62	 */
63	private $codeLength = 6;
64
65	/**
66	 * Range plus/minus for "window of opportunity" on allowed codes
67	 * @var integer
68	 */
69	private $range = 2;
70
71	/**
72	 * Initialize the object and set up the lookup table
73	 *     Optionally the Initialization key
74	 *
75	 * @param string $initKey Initialization key
76	 */
77	public function __construct($initKey = null)
78	{
79		$this->buildLookup();
80
81		if ($initKey !== null) {
82			$this->setInitKey($initKey);
83		}
84	}
85
86	/**
87	 * Build the base32 lookup table
88	 *
89	 * @return null
90	 */
91	public function buildLookup()
92	{
93		$lookup = array_combine(
94			array_merge(range('A', 'Z'), range(2, 7)),
95			range(0, 31)
96		);
97		$this->setLookup($lookup);
98	}
99
100	/**
101	 * Get the current "range" value
102	 * @return integer Range value
103	 */
104	public function getRange()
105	{
106		return $this->range;
107	}
108
109	/**
110	 * Set the "range" value
111	 *
112	 * @param integer $range Range value
113	 * @return \GAuth\Auth instance
114	 */
115	public function setRange($range)
116	{
117		if (!is_numeric($range)) {
118			throw new \InvalidArgumentException('Invalid window range');
119		}
120		$this->range = $range;
121		return $this;
122	}
123
124	/**
125	 * Set the initialization key for the object
126	 *
127	 * @param string $key Initialization key
128	 * @throws \InvalidArgumentException If hash is not valid base32
129	 * @return \GAuth\Auth instance
130	 */
131	public function setInitKey($key)
132	{
133		if (preg_match('/^['.implode('', array_keys($this->getLookup())).']+$/', $key) == false) {
134			throw new \InvalidArgumentException('Invalid base32 hash!');
135		}
136		$this->initKey = $key;
137		return $this;
138	}
139
140	/**
141	 * Get the current Initialization key
142	 *
143	 * @return string Initialization key
144	 */
145	public function getInitKey()
146	{
147		return $this->initKey;
148	}
149
150	/**
151	 * Set the contents of the internal lookup table
152	 *
153	 * @param array $lookup Lookup data set
154	 * @throws \InvalidArgumentException If lookup given is not an array
155	 * @return \GAuth\Auth instance
156	 */
157	public function setLookup($lookup)
158	{
159		if (!is_array($lookup)) {
160			throw new \InvalidArgumentException('Lookup value must be an array');
161		}
162		$this->lookup = $lookup;
163		return $this;
164	}
165
166	/**
167	 * Get the current lookup data set
168	 *
169	 * @return array Lookup data
170	 */
171	public function getLookup()
172	{
173		return $this->lookup;
174	}
175
176	/**
177	 * Get the number of seconds for code refresh currently set
178	 *
179	 * @return integer Refresh in seconds
180	 */
181	public function getRefresh()
182	{
183		return $this->refreshSeconds;
184	}
185
186	/**
187	 * Set the number of seconds to refresh codes
188	 *
189	 * @param integer $seconds Seconds to refresh
190	 * @throws \InvalidArgumentException If seconds value is not numeric
191	 * @return \GAuth\Auth instance
192	 */
193	public function setRefresh($seconds)
194	{
195		if (!is_numeric($seconds)) {
196			throw \InvalidArgumentException('Seconds must be numeric');
197		}
198		$this->refreshSeconds = $seconds;
199		return $this;
200	}
201
202	/**
203	 * Get the current length for generated codes
204	 *
205	 * @return integer Code length
206	 */
207	public function getCodeLength()
208	{
209		return $this->codeLength;
210	}
211
212	/**
213	 * Set the length of the generated codes
214	 *
215	 * @param integer $length Code length
216	 * @return \GAuth\Auth instance
217	 */
218	public function setCodeLength($length)
219	{
220		$this->codeLength = $length;
221		return $this;
222	}
223
224	/**
225	 * Validate the given code
226	 *
227	 * @param string $code Code entered by user
228	 * @param string $initKey Initialization key
229	 * @param string $timestamp Timestamp for calculation
230	 * @param integer $range Seconds before/after to validate hash against
231	 * @throws \InvalidArgumentException If incorrect code length
232	 * @return boolean Pass/fail of validation
233	 */
234	public function validateCode($code, $initKey = null, $timestamp = null, $range = null)
235	{
236		if (strlen($code) !== $this->getCodeLength()) {
237			throw new \InvalidArgumentException('Incorrect code length');
238		}
239
240		$range = ($range == null) ? $this->getRange() : $range;
241		$timestamp = ($timestamp == null) ? $this->generateTimestamp() : $timestamp;
242		$initKey = ($initKey == null) ? $this->getInitKey() : $initKey;
243
244		$binary = $this->base32_decode($initKey);
245
246		for ($time = ($timestamp - $range); $time <= ($timestamp + $range); $time++) {
247			if ($this->generateOneTime($binary, $time) == $code) {
248				return true;
249			}
250		}
251		return false;
252	}
253
254	/**
255	 * Generate a one-time code
256	 *
257	 * @param string $initKey Initialization key [optional]
258	 * @param string $timestamp Timestamp for calculation [optional]
259	 * @return string Geneerated code/hash
260	 */
261	public function generateOneTime($initKey = null, $timestamp = null)
262	{
263		$initKey = ($initKey == null) ? $this->getInitKey() : $initKey;
264		$timestamp = ($timestamp == null) ? $this->generateTimestamp() : $timestamp;
265
266		$hash = hash_hmac (
267			'sha1',
268			pack('N*', 0) . pack('N*', $timestamp),
269			$initKey,
270			true
271		);
272
273		return str_pad($this->truncateHash($hash), $this->getCodeLength(), '0', STR_PAD_LEFT);
274	}
275
276	/**
277	 * Generate a code/hash
278	 *     Useful for making Initialization codes
279	 *
280	 * @param integer $length Length for the generated code
281	 * @return string Generated code
282	 */
283	public function generateCode($length = 16)
284	{
285		$lookup = implode('', array_keys($this->getLookup()));
286		$code = '';
287
288		for ($i = 0; $i < $length; $i++) {
289			$code .= $lookup[mt_rand(0, strlen($lookup)-1)];
290		}
291
292		return $code;
293	}
294
295	/**
296	 * Geenrate the timestamp for the calculation
297	 *
298	 * @return integer Timestamp
299	 */
300	public function generateTimestamp()
301	{
302		return floor(microtime(true)/$this->getRefresh());
303	}
304
305	/**
306	 * Truncate the given hash down to just what we need
307	 *
308	 * @param string $hash Hash to truncate
309	 * @return string Truncated hash value
310	 */
311	public function truncateHash($hash)
312	{
313		$offset = ord($hash[19]) & 0xf;
314
315		return (
316			((ord($hash[$offset+0]) & 0x7f) << 24 ) |
317			((ord($hash[$offset+1]) & 0xff) << 16 ) |
318			((ord($hash[$offset+2]) & 0xff) << 8 ) |
319			(ord($hash[$offset+3]) & 0xff)
320		) % pow(10, $this->getCodeLength());
321	}
322
323	/**
324	 * Base32 decoding function
325	 *
326	 * @param string base32 encoded hash
327	 * @throws \InvalidArgumentException When hash is not valid
328	 * @return string Binary value of hash
329	 */
330	public function base32_decode($hash)
331	{
332		$lookup = $this->getLookup();
333
334		if (preg_match('/^['.implode('', array_keys($lookup)).']+$/', $hash) == false) {
335			throw new \InvalidArgumentException('Invalid base32 hash!');
336		}
337
338		$hash = strtoupper($hash);
339		$buffer = 0;
340		$length = 0;
341		$binary = '';
342
343		for ($i = 0; $i < strlen($hash); $i++) {
344			$buffer = $buffer << 5;
345			$buffer += $lookup[$hash[$i]];
346			$length += 5;
347
348			if ($length >= 8) {
349				$length -= 8;
350				$binary .= chr(($buffer & (0xFF << $length)) >> $length);
351			}
352		}
353
354		return $binary;
355	}
356}