regenerateScratchTokens(); return $object; } /** * @param string $secret * @param array $scratchTokens */ public function __construct( $secret, array $scratchTokens ) { // Currently harcoded values; might be used in future $this->secret = [ 'mode' => 'hotp', 'secret' => $secret, 'period' => 30, 'algorithm' => 'SHA1', ]; $this->scratchTokens = $scratchTokens; $this->logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' ); } /** * @return string */ public function getSecret() { return $this->secret['secret']; } /** * @return array */ public function getScratchTokens() { return $this->scratchTokens; } /** * Verify a token against the secret or scratch tokens * * @param string $token Token to verify * @param OATHUser $user * * @return int|false Returns a constant represent what type of token was matched, * or false for no match */ public function verifyToken( $token, OATHUser $user ) { global $wgOATHAuthWindowRadius; if ( $this->secret['mode'] !== 'hotp' ) { throw new \DomainException( 'OATHAuth extension does not support non-HOTP tokens' ); } // Prevent replay attacks $memc = ObjectCache::newAnything( [] ); $uid = CentralIdLookup::factory()->centralIdFromLocalUser( $user->getUser() ); $memcKey = wfMemcKey( 'oathauth', 'usedtokens', $uid ); $lastWindow = (int)$memc->get( $memcKey ); $retval = false; $results = HOTP::generateByTimeWindow( Base32::decode( $this->secret['secret'] ), $this->secret['period'], -$wgOATHAuthWindowRadius, $wgOATHAuthWindowRadius ); // Remove any whitespace from the received token, which can be an intended group seperator // or trimmeable whitespace $token = preg_replace( '/\s+/', '', $token ); $clientIP = $user->getUser()->getRequest()->getIP(); // Check to see if the user's given token is in the list of tokens generated // for the time window. foreach ( $results as $window => $result ) { if ( $window > $lastWindow && $result->toHOTP( 6 ) === $token ) { $lastWindow = $window; $retval = self::MAIN_TOKEN; $this->logger->info( 'OATHAuth user {user} entered a valid OTP from {clientip}', [ 'user' => $user->getAccount(), 'clientip' => $clientIP, ] ); break; } } // See if the user is using a scratch token if ( !$retval ) { $length = count( $this->scratchTokens ); // Detect condition where all scratch tokens have been used if ( $length === 1 && $this->scratchTokens[0] === "" ) { $retval = false; } else { for ( $i = 0; $i < $length; $i++ ) { if ( $token === $this->scratchTokens[$i] ) { // If there is a scratch token, remove it from the scratch token list unset( $this->scratchTokens[$i] ); $this->logger->info( 'OATHAuth user {user} used a scratch token from {clientip}', [ 'user' => $user->getAccount(), 'clientip' => $clientIP, ] ); $oathrepo = OATHAuthHooks::getOATHUserRepository(); $user->setKey( $this ); $oathrepo->persist( $user, $clientIP ); // Only return true if we removed it from the database $retval = self::SCRATCH_TOKEN; break; } } } } if ( $retval ) { $memc->set( $memcKey, $lastWindow, $this->secret['period'] * ( 1 + 2 * $wgOATHAuthWindowRadius ) ); } else { $this->logger->info( 'OATHAuth user {user} failed OTP/scratch token from {clientip}', [ 'user' => $user->getAccount(), 'clientip' => $clientIP, ] ); // Increase rate limit counter for failed request $user->getUser()->pingLimiter( 'badoath' ); } return $retval; } public function regenerateScratchTokens() { $scratchTokens = []; for ( $i = 0; $i < 5; $i++ ) { $scratchTokens[] = Base32::encode( random_bytes( 10 ) ); } $this->scratchTokens = $scratchTokens; } /** * Check if a token is one of the scratch tokens for this two factor key. * * @param string $token Token to verify * * @return bool true if this is a scratch token. */ public function isScratchToken( $token ) { $token = preg_replace( '/\s+/', '', $token ); return in_array( $token, $this->scratchTokens, true ); } }