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