Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.50% |
77 / 88 |
|
91.67% |
11 / 12 |
CRAP | |
0.00% |
0 / 1 |
| TOTPKey | |
87.50% |
77 / 88 |
|
91.67% |
11 / 12 |
24.03 | |
0.00% |
0 / 1 |
| newFromRandom | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| removeBase32Padding | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| newFromArray | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
| __construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| getSecret | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setEncryptedSecretAndNonce | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getEncryptedSecretAndNonce | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| verify | |
65.62% |
21 / 32 |
|
0.00% |
0 / 1 |
7.46 | |||
| getModule | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getLogger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| jsonSerialize | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
| getEncryptionHelper | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * @license GPL-2.0-or-later |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Extension\OATHAuth\Key; |
| 8 | |
| 9 | use Base32\Base32; |
| 10 | use DomainException; |
| 11 | use jakobo\HOTP\HOTP; |
| 12 | use MediaWiki\Context\RequestContext; |
| 13 | use MediaWiki\Extension\OATHAuth\Module\TOTP; |
| 14 | use MediaWiki\Extension\OATHAuth\OATHAuthServices; |
| 15 | use MediaWiki\Extension\OATHAuth\OATHUser; |
| 16 | use MediaWiki\Logger\LoggerFactory; |
| 17 | use MediaWiki\MediaWikiServices; |
| 18 | use Psr\Log\LoggerInterface; |
| 19 | use UnexpectedValueException; |
| 20 | use Wikimedia\ObjectCache\EmptyBagOStuff; |
| 21 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
| 22 | use Wikimedia\Timestamp\TimestampFormat; |
| 23 | |
| 24 | /** |
| 25 | * Class representing a two-factor key |
| 26 | * |
| 27 | * Keys can be tied to OATHUsers |
| 28 | * |
| 29 | * @ingroup Extensions |
| 30 | */ |
| 31 | class TOTPKey extends AuthKey { |
| 32 | /** TOTP binary secret */ |
| 33 | private array $secret; |
| 34 | |
| 35 | public static function newFromRandom(): TOTPKey { |
| 36 | return new self( |
| 37 | null, |
| 38 | null, |
| 39 | null, |
| 40 | // 26 digits to give 128 bits - https://phabricator.wikimedia.org/T396951 |
| 41 | self::removeBase32Padding( Base32::encode( random_bytes( 26 ) ) ), |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * @param string $paddedBase32String |
| 47 | * @return string |
| 48 | * @see T408225, T401393 |
| 49 | */ |
| 50 | public static function removeBase32Padding( string $paddedBase32String ) { |
| 51 | return rtrim( $paddedBase32String, '=' ); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * @param array $data |
| 56 | * @return TOTPKey|null on invalid data |
| 57 | * @throws UnexpectedValueException When encryption is not configured but db is encrypted |
| 58 | */ |
| 59 | public static function newFromArray( array $data ) { |
| 60 | if ( !isset( $data['secret'] ) ) { |
| 61 | return null; |
| 62 | } |
| 63 | |
| 64 | if ( isset( $data['nonce'] ) ) { |
| 65 | $encryptionHelper = self::getEncryptionHelper(); |
| 66 | if ( !$encryptionHelper->isEnabled() ) { |
| 67 | // @codeCoverageIgnoreStart |
| 68 | throw new UnexpectedValueException( |
| 69 | 'Encryption is not configured but OATHAuth is attempting to use encryption' |
| 70 | ); |
| 71 | // @codeCoverageIgnoreEnd |
| 72 | } |
| 73 | $data['encrypted_secret'] = $data['secret']; |
| 74 | $data['secret'] = $encryptionHelper->decrypt( $data['secret'], $data['nonce'] ); |
| 75 | } else { |
| 76 | $data['encrypted_secret'] = ''; |
| 77 | $data['nonce'] = ''; |
| 78 | } |
| 79 | |
| 80 | return new static( |
| 81 | $data['id'] ?? null, |
| 82 | $data['friendly_name'] ?? null, |
| 83 | $data['created_timestamp'] ?? null, |
| 84 | $data['secret'] ?? '', |
| 85 | $data['encrypted_secret'], |
| 86 | $data['nonce'] |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | public function __construct( |
| 91 | ?int $id, |
| 92 | ?string $friendlyName, |
| 93 | ?string $createdTimestamp, |
| 94 | string $secret, |
| 95 | string $encryptedSecret = '', |
| 96 | string $nonce = '' |
| 97 | ) { |
| 98 | parent::__construct( $id, $friendlyName, $createdTimestamp ); |
| 99 | // Currently hardcoded values; might be used in the future |
| 100 | $this->secret = [ |
| 101 | 'mode' => 'hotp', |
| 102 | 'secret' => $secret, |
| 103 | 'period' => 30, |
| 104 | 'algorithm' => 'SHA1', |
| 105 | 'encrypted_secret' => $encryptedSecret, |
| 106 | 'nonce' => $nonce |
| 107 | ]; |
| 108 | } |
| 109 | |
| 110 | public function getSecret(): string { |
| 111 | return $this->secret['secret']; |
| 112 | } |
| 113 | |
| 114 | public function setEncryptedSecretAndNonce( string $encryptedSecret, string $nonce ) { |
| 115 | $this->secret['encrypted_secret'] = $encryptedSecret; |
| 116 | $this->secret['nonce'] = $nonce; |
| 117 | } |
| 118 | |
| 119 | public function getEncryptedSecretAndNonce(): array { |
| 120 | return [ |
| 121 | $this->secret['encrypted_secret'], |
| 122 | $this->secret['nonce'], |
| 123 | ]; |
| 124 | } |
| 125 | |
| 126 | public function verify( OATHUser $user, array $data ): bool { |
| 127 | global $wgOATHAuthWindowRadius; |
| 128 | |
| 129 | $token = $data['token'] ?? ''; |
| 130 | |
| 131 | if ( $this->secret['mode'] !== 'hotp' ) { |
| 132 | // @codeCoverageIgnoreStart |
| 133 | throw new DomainException( 'OATHAuth extension does not support non-HOTP tokens' ); |
| 134 | // @codeCoverageIgnoreEnd |
| 135 | } |
| 136 | |
| 137 | // Prevent replay attacks |
| 138 | $services = MediaWikiServices::getInstance(); |
| 139 | $store = $services->getMainObjectStash(); |
| 140 | |
| 141 | if ( $store instanceof EmptyBagOStuff ) { |
| 142 | // @codeCoverageIgnoreStart |
| 143 | // Try and find some usable cache if the MainObjectStash isn't useful |
| 144 | $store = $services->getObjectCacheFactory()->getLocalServerInstance( CACHE_ANYTHING ); |
| 145 | // @codeCoverageIgnoreEnd |
| 146 | } |
| 147 | |
| 148 | $key = $store->makeKey( 'oathauth-totp', 'usedtokens', $user->getCentralId() ); |
| 149 | $lastWindow = (int)$store->get( $key ); |
| 150 | |
| 151 | $results = HOTP::generateByTimeWindow( |
| 152 | Base32::decode( $this->secret['secret'] ), |
| 153 | $this->secret['period'], |
| 154 | -$wgOATHAuthWindowRadius, |
| 155 | $wgOATHAuthWindowRadius, |
| 156 | (int)ConvertibleTimestamp::now( TimestampFormat::UNIX ) |
| 157 | ); |
| 158 | |
| 159 | // Remove any whitespace from the received token, which can be an intended group separator |
| 160 | $token = preg_replace( '/\s+/', '', $token ); |
| 161 | |
| 162 | $clientIP = RequestContext::getMain()->getRequest()->getIP(); |
| 163 | |
| 164 | $logger = $this->getLogger(); |
| 165 | |
| 166 | // Check to see if the user's given token is in the list of tokens generated |
| 167 | // for the time window. |
| 168 | foreach ( $results as $window => $result ) { |
| 169 | if ( $window <= $lastWindow || !hash_equals( $result->toHOTP( 6 ), $token ) ) { |
| 170 | continue; |
| 171 | } |
| 172 | |
| 173 | $lastWindow = $window; |
| 174 | |
| 175 | $logger->info( 'OATHAuth user {user} entered a valid OTP from {clientip}', [ |
| 176 | 'user' => $user->getAccount(), |
| 177 | 'clientip' => $clientIP, |
| 178 | ] ); |
| 179 | |
| 180 | $store->set( |
| 181 | $key, |
| 182 | $lastWindow, |
| 183 | $this->secret['period'] * ( 1 + 2 * $wgOATHAuthWindowRadius ) |
| 184 | ); |
| 185 | |
| 186 | return true; |
| 187 | } |
| 188 | |
| 189 | return false; |
| 190 | } |
| 191 | |
| 192 | /** @inheritDoc */ |
| 193 | public function getModule(): string { |
| 194 | return TOTP::MODULE_NAME; |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * @return LoggerInterface |
| 199 | */ |
| 200 | private function getLogger(): LoggerInterface { |
| 201 | return LoggerFactory::getInstance( 'authentication' ); |
| 202 | } |
| 203 | |
| 204 | public function jsonSerialize(): array { |
| 205 | $encryptedData = $this->getEncryptedSecretAndNonce(); |
| 206 | $encryptionHelper = self::getEncryptionHelper(); |
| 207 | if ( $encryptionHelper->isEnabled() && in_array( '', $encryptedData ) ) { |
| 208 | $data = $encryptionHelper->encrypt( $this->getSecret() ); |
| 209 | $this->setEncryptedSecretAndNonce( $data['secret'], $data['nonce'] ); |
| 210 | } elseif ( $encryptionHelper->isEnabled() ) { |
| 211 | $data = [ |
| 212 | 'secret' => $encryptedData[0], |
| 213 | 'nonce' => $encryptedData[1] |
| 214 | ]; |
| 215 | } else { |
| 216 | $data = [ 'secret' => $this->getSecret() ]; |
| 217 | } |
| 218 | |
| 219 | $data['friendly_name'] = $this->getFriendlyName(); |
| 220 | return $data; |
| 221 | } |
| 222 | |
| 223 | private static function getEncryptionHelper(): EncryptionHelper { |
| 224 | return OATHAuthServices::getInstance()->getEncryptionHelper(); |
| 225 | } |
| 226 | } |