1<?php
2
3/**
4 * TwoFactorAuthentication backend
5 *
6 * Provides the basis of abstracting 2fa backends
7
8 * The authentication backend should define a validate() method which
9 * receives a user and OPT.
10 */
11abstract class TwoFactorAuthenticationBackend {
12    // Global registry
13    static protected $registry = array();
14    // Grace period in minutes before OTP is expired and user logged out
15    // It's hardcoded to 6 minutes here but downstream backends can make it
16    // configurable
17    protected $timeout = 6;
18
19    // Maximum number of validation attempts before the user is logged out
20    // It's hardcoded to 3 attempts here but downstream backends can make it
21    // configurable
22    protected $maxstrikes = 3;
23
24    // Base properties
25    static $id;
26    static $name;
27    static $desc;
28
29    // Forms
30    private $_setupform;
31    private $_inputform;
32
33
34    // Send OTP to user specified and stash it
35    abstract function send($user);
36    // validate OTP provided by user
37    abstract function validate($form, $user);
38
39    function getId() {
40        return static::$id;
41    }
42
43    function getName() {
44        return __(static::$name);
45    }
46
47    function getDescription() {
48        return __(static::$desc);
49    }
50
51    function getTimeout() {
52        return $this->timeout;
53    }
54
55    function getMaxStrikes() {
56        return $this->maxstrikes;
57    }
58
59    protected function getSetupOptions() {
60        return array();
61    }
62
63    protected function getInputOptions() {
64        return array();
65    }
66
67    // stash OTP info in the session
68    protected function store($otp) {
69       $store =  &$_SESSION['_2fa'][$this->getId()];
70       $store = ['otp' => $otp, 'time' => time(), 'strikes' => 0];
71       return $store;
72    }
73
74    // Validate OPT
75    // On strict mode check strikes and timeout
76    protected function _validate($otp, $strict=true) {
77        $store = &$_SESSION['_2fa'][$this->getId()];
78        // Track and check the attempts
79        $store['strikes'] += 1;
80        if ($strict && $store['strikes'] > $this->getMaxStrikes())
81            throw new ExpiredOTP(__('Too many attempts'));
82
83        // Check timeout - if expired throw an exception.
84        if ($strict
85                && ($timeout=$this->getTimeout())
86                && ($store['time']+($timeout*60)) < time())
87            throw new ExpiredOTP(__('Expired OTP'));
88
89        // Check the OTP
90        return (!strcmp($store['otp'], $otp));
91    }
92
93    // Called on a successfull validation for house keeping e.g clear 2fa
94    // flags
95    protected function onValidate($user) {
96         $user->clear2FA();
97    }
98
99    // Get a form the user uses to setup 2fa
100    function getSetupForm($data=null) {
101        if (!$this->_setupForm) {
102            $this->_setupForm = new SimpleForm($this->getSetupOptions(), $data);
103        }
104        return $this->_setupForm;
105    }
106
107    // Get a form the user uses to input OTP
108    function getInputForm($data=null) {
109        if (!$this->_inputForm) {
110            $this->_inputForm = new SimpleForm($this->getInputOptions(), $data);
111        }
112        return $this->_inputForm;
113    }
114
115    static function register($class) {
116        if (is_string($class) && class_exists($class))
117            $class = new $class();
118
119        if (!is_object($class)
120                || !($class instanceof TwoFactorAuthenticationBackend))
121            return false;
122
123        return static::_register($class);
124    }
125
126    static function _register($class) {
127        if (isset(static::$registry[$class::$id]))
128            return false;
129
130        static::$registry[$class::$id] = $class;
131    }
132
133    static function allRegistered() {
134        return static::$registry;
135    }
136
137    static function getBackend($id) {
138
139        if ($id
140                && ($backends = static::allRegistered())
141                && isset($backends[$id]))
142            return $backends[$id];
143    }
144
145    static function lookup($id) {
146        return static::getBackend($id);
147    }
148}
149
150class ExpiredOTP extends Exception {}
151
152/*
153 * user type container classes to aid in registry segmentation
154 *
155 */
156abstract class Staff2FABackend extends TwoFactorAuthenticationBackend {
157    static private $_registry = array();
158
159    static function _register($class) {
160        if (isset(static::$_registry[$class::$id]))
161            return false;
162
163        static::$_registry[$class::$id] = $class;
164    }
165
166    static function allRegistered() {
167        return array_merge(self::$_registry, parent::allRegistered());
168    }
169
170
171    abstract function send($user);
172    abstract function validate($form, $user);
173}
174
175abstract class User2FABackend extends TwoFactorAuthenticationBackend {
176    static private $_registry = array();
177
178    static function _register($class) {
179        if (isset(static::$_registry[$class::$id]))
180            return false;
181
182        static::$_registry[$class::$id] = $class;
183    }
184
185    static function allRegistered() {
186        return array_merge(self::$_registry, parent::allRegistered());
187    }
188
189    abstract function send($user);
190    abstract function validate($form, $user);
191}
192
193/*
194 * Email2FABackend
195 *
196 * Email based two factor authentication.
197 *
198 * This is the default 2FA that works out of the box once users configure
199 * it.
200 *
201 */
202
203class Email2FABackend extends TwoFactorAuthenticationBackend {
204    static $id = "2fa-email";
205    static $name = /* @trans */ 'Email';
206    static $desc = /* @trans */ 'Verification codes are sent by email';
207
208    protected function getSetupOptions() {
209        return array(
210            'email' => new TextboxField(array(
211                'id'=>2, 'label'=>__('Email Address'), 'required'=>true, 'default'=>'',
212                'validator'=>'email', 'hint'=>__('Valid email address'),
213                'configuration'=>array('size'=>40, 'length'=>40),
214            )),
215        );
216    }
217
218    protected function getInputOptions() {
219        return array(
220            'token' => new TextboxField(array(
221                'id'=>1, 'label'=>__('Verification Code'), 'required'=>true, 'default'=>'',
222                'validator'=>'number',
223                'hint'=>__('Please enter the code you were sent'),
224                'configuration'=>array(
225                    'size'=>40, 'length'=>40,
226                    'autocomplete' => 'one-time-code',
227                    'inputmode' => 'numeric',
228                    'pattern' => '[0-9]*',
229                    'validator-error' => __('Invalid Code format'),
230                    ),
231            )),
232        );
233    }
234
235    function validate($form, $user) {
236        // Make sure form is valid and token exists
237        if (!($form->isValid()
238                    && ($clean=$form->getClean())
239                    && $clean['token']))
240            return false;
241
242        // upstream validation might throw an exception due to expired token
243        // or too many attempts (timeout). It's the responsibility of the
244        // caller to catch and handle such exceptions.
245        if (!$this->_validate($clean['token']))
246            return false;
247
248        // Validator doesn't do house cleaning - it's our responsibility
249        $this->onValidate($user);
250        return true;
251    }
252
253    function send($user) {
254        global $ost, $cfg;
255
256        // Get backend configuration for this user
257        if (!$cfg || !($info = $user->get2FAConfig($this->getId())))
258            return false;
259
260        // Email to send the OTP via
261        if (!($email = $cfg->getAlertEmail() ?: $cfg->getDefaultEmail()))
262            return false;
263
264        // Generate OTP
265        $otp = Misc::randNumber(6);
266        // Stash it in the session
267        $this->store($otp);
268
269        $template = 'email2fa-staff';
270        $content = Page::lookupByType($template);
271
272        if (!$content)
273           return new BaseError(/* @trans */ 'Unable to retrieve two factor authentication email template');
274
275        $vars = array(
276           'url' => $ost->getConfig()->getBaseUrl(),
277           'otp' => $otp,
278           'staff' => $user,
279           'recipient' => $user,
280       );
281
282       $lang = $user->lang ?: $user->getExtraAttr('browser_lang');
283       $msg = $ost->replaceTemplateVariables(array(
284           'subj' => $content->getLocalName($lang),
285           'body' => $content->getLocalBody($lang),
286       ), $vars);
287
288        $email->send($user->getEmail(), Format::striptags($msg['subj']),
289           $msg['body']);
290
291        // MD5 here is not meant to be secure here - just done to avoid plain leaks
292        return md5($otp);
293    }
294}
295// Register email2fa for both agents and users (parent class)
296TwoFactorAuthenticationBackend::register('Email2FABackend');
297?>
298