Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 159 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
TOTPEnableForm | |
0.00% |
0 / 159 |
|
0.00% |
0 / 11 |
272 | |
0.00% |
0 / 1 |
getHTML | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
onSuccess | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDescriptors | |
0.00% |
0 / 93 |
|
0.00% |
0 / 1 |
6 | |||
createResourceList | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
createTextList | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
createDownloadLink | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
2 | |||
createCopyButton | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getSecretForDisplay | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getScratchTokensForDisplay | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
tokenFormatterFunction | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onSubmit | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\OATHAuth\HTMLForm; |
4 | |
5 | use Endroid\QrCode\Builder\Builder; |
6 | use Endroid\QrCode\Encoding\Encoding; |
7 | use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; |
8 | use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeNone; |
9 | use Endroid\QrCode\Writer\SvgWriter; |
10 | use MediaWiki\Config\ConfigException; |
11 | use MediaWiki\Extension\OATHAuth\Key\TOTPKey; |
12 | use MediaWiki\Html\Html; |
13 | use MediaWiki\Logger\LoggerFactory; |
14 | use MediaWiki\Status\Status; |
15 | use MWException; |
16 | |
17 | class TOTPEnableForm extends OATHAuthOOUIHTMLForm { |
18 | /** |
19 | * @param array|bool|Status|string $submitResult |
20 | * @return string |
21 | */ |
22 | public function getHTML( $submitResult ) { |
23 | $out = $this->getOutput(); |
24 | $out->addModuleStyles( 'ext.oath.styles' ); |
25 | $out->addModules( 'ext.oath' ); |
26 | |
27 | return parent::getHTML( $submitResult ); |
28 | } |
29 | |
30 | /** |
31 | * Add content to output when operation was successful |
32 | */ |
33 | public function onSuccess() { |
34 | $this->getOutput()->addWikiMsg( 'oathauth-validatedoath' ); |
35 | } |
36 | |
37 | /** |
38 | * @return array |
39 | */ |
40 | protected function getDescriptors() { |
41 | $keyData = $this->getRequest()->getSessionData( 'oathauth_totp_key' ) ?? []; |
42 | $key = TOTPKey::newFromArray( $keyData ); |
43 | if ( !$key instanceof TOTPKey ) { |
44 | $key = TOTPKey::newFromRandom(); |
45 | $this->getRequest()->setSessionData( |
46 | 'oathauth_totp_key', |
47 | $key->jsonSerialize() |
48 | ); |
49 | } |
50 | |
51 | $secret = $key->getSecret(); |
52 | $issuer = $this->oathUser->getIssuer(); |
53 | $account = $this->oathUser->getAccount(); |
54 | $label = "{$issuer}:{$account}"; |
55 | $qrcodeUrl = "otpauth://totp/" |
56 | . rawurlencode( $label ) |
57 | . "?secret=" |
58 | . rawurlencode( $secret ) |
59 | . "&issuer=" |
60 | . rawurlencode( $issuer ); |
61 | |
62 | $qrCode = Builder::create() |
63 | ->writer( new SvgWriter() ) |
64 | ->writerOptions( [ SvgWriter::WRITER_OPTION_EXCLUDE_XML_DECLARATION => true ] ) |
65 | ->data( $qrcodeUrl ) |
66 | ->encoding( new Encoding( 'UTF-8' ) ) |
67 | ->errorCorrectionLevel( new ErrorCorrectionLevelHigh() ) |
68 | ->roundBlockSizeMode( new RoundBlockSizeModeNone() ) |
69 | ->size( 256 ) |
70 | ->margin( 0 ) |
71 | ->build(); |
72 | |
73 | $now = wfTimestampNow(); |
74 | $recoveryCodes = $this->getScratchTokensForDisplay( $key ); |
75 | $this->getOutput()->addJsConfigVars( 'oathauth-recoverycodes', $this->createTextList( $recoveryCodes ) ); |
76 | |
77 | // messages used: oathauth-step1, oathauth-step2, oathauth-step3, oathauth-step4 |
78 | return [ |
79 | 'app' => [ |
80 | 'type' => 'info', |
81 | 'default' => $this->msg( 'oathauth-step1-test' )->parse(), |
82 | 'raw' => true, |
83 | 'section' => 'step1', |
84 | ], |
85 | 'qrcode' => [ |
86 | 'type' => 'info', |
87 | 'default' => |
88 | $this->msg( 'oathauth-step2-qrcode' )->escaped() . '<br/>' |
89 | . Html::element( 'img', [ |
90 | 'src' => $qrCode->getDataUri(), |
91 | 'alt' => $this->msg( 'oathauth-qrcode-alt' ), |
92 | 'width' => 256, |
93 | 'height' => 256, |
94 | ] ), |
95 | 'raw' => true, |
96 | 'section' => 'step2', |
97 | ], |
98 | 'manual' => [ |
99 | 'type' => 'info', |
100 | 'label-message' => 'oathauth-step2alt', |
101 | 'default' => |
102 | '<strong>' . $this->msg( 'oathauth-secret' )->escaped() . '</strong><br/>' |
103 | . '<kbd>' . $this->getSecretForDisplay( $key ) . '</kbd><br/>' |
104 | . '<strong>' . $this->msg( 'oathauth-account' )->escaped() . '</strong><br/>' |
105 | . htmlspecialchars( $label ) . '<br/><br/>', |
106 | 'raw' => true, |
107 | 'section' => 'step2', |
108 | ], |
109 | 'scratchtokens' => [ |
110 | 'type' => 'info', |
111 | 'default' => |
112 | '<strong>' . $this->msg( 'oathauth-recoverycodes-important' )->escaped() . '</strong><br/>' . |
113 | $this->msg( 'oathauth-recoverycodes' )->escaped() . '<br/><br/>' . |
114 | $this->msg( 'rawmessage' )->rawParams( |
115 | $this->msg( |
116 | 'oathauth-recoverytokens-createdat', |
117 | $this->getLanguage()->userTimeAndDate( $now, $this->oathUser->getUser() ) |
118 | )->parse() |
119 | . $this->msg( 'word-separator' )->escaped() |
120 | . $this->msg( 'parentheses' )->rawParams( wfTimestamp( TS_ISO_8601, $now ) )->escaped() |
121 | ) . '<br/>' . |
122 | $this->createResourceList( $recoveryCodes ) . '<br/>' . |
123 | '<strong>' . $this->msg( 'oathauth-recoverycodes-neveragain' )->escaped() . '</strong><br/>' . |
124 | $this->createCopyButton() . |
125 | $this->createDownloadLink( $recoveryCodes ), |
126 | 'raw' => true, |
127 | 'section' => 'step3', |
128 | ], |
129 | 'token' => [ |
130 | 'type' => 'text', |
131 | 'default' => '', |
132 | 'label-message' => 'oathauth-entertoken', |
133 | 'name' => 'token', |
134 | 'section' => 'step4', |
135 | 'dir' => 'ltr', |
136 | 'autocomplete' => 'one-time-code', |
137 | 'spellcheck' => false, |
138 | ] |
139 | ]; |
140 | } |
141 | |
142 | /** |
143 | * @param array $resources |
144 | * @return string |
145 | */ |
146 | private function createResourceList( $resources ) { |
147 | $resourceList = ''; |
148 | foreach ( $resources as $resource ) { |
149 | $resourceList .= Html::rawElement( 'li', [], Html::rawElement( 'kbd', [], $resource ) ); |
150 | } |
151 | return Html::rawElement( 'ul', [], $resourceList ); |
152 | } |
153 | |
154 | /** |
155 | * @param array $items |
156 | * |
157 | * @return string |
158 | */ |
159 | private function createTextList( $items ) { |
160 | return "* " . implode( "\n* ", $items ); |
161 | } |
162 | |
163 | private function createDownloadLink( array $scratchTokensForDisplay ): string { |
164 | $icon = Html::element( 'span', [ |
165 | 'class' => [ 'mw-oathauth-recoverycodes-download-icon', 'cdx-button__icon' ], |
166 | 'aria-hidden' => 'true', |
167 | ] ); |
168 | return Html::rawElement( |
169 | 'a', |
170 | [ |
171 | 'href' => 'data:text/plain;charset=utf-8,' |
172 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1895687 |
173 | . rawurlencode( implode( PHP_EOL, $scratchTokensForDisplay ) ), |
174 | 'download' => 'recovery-codes.txt', |
175 | 'class' => [ |
176 | 'mw-oathauth-recoverycodes-download', |
177 | 'cdx-button', 'cdx-button--fake-button', 'cdx-button--fake-button--enabled', |
178 | ], |
179 | ], |
180 | $icon . $this->msg( 'oathauth-recoverycodes-download' )->escaped() |
181 | ); |
182 | } |
183 | |
184 | private function createCopyButton(): string { |
185 | return Html::rawElement( 'button', [ |
186 | 'class' => 'cdx-button mw-oathauth-recoverycodes-copy-button' |
187 | ], Html::element( 'span', [ |
188 | 'class' => 'cdx-button__icon', |
189 | 'aria-hidden' => 'true', |
190 | ] ) . $this->msg( 'oathauth-recoverycodes-copy' )->escaped() |
191 | ); |
192 | } |
193 | |
194 | /** |
195 | * Retrieve the current secret for display purposes |
196 | * |
197 | * The characters of the token are split in groups of 4 |
198 | * |
199 | * @param TOTPKey $key |
200 | * @return string |
201 | */ |
202 | protected function getSecretForDisplay( TOTPKey $key ) { |
203 | return $this->tokenFormatterFunction( $key->getSecret() ); |
204 | } |
205 | |
206 | /** |
207 | * Retrieve current recovery codes for display purposes |
208 | * |
209 | * The characters of the token are split in groups of 4 |
210 | * |
211 | * @param TOTPKey $key |
212 | * @return string[] |
213 | */ |
214 | protected function getScratchTokensForDisplay( TOTPKey $key ) { |
215 | return array_map( [ $this, 'tokenFormatterFunction' ], $key->getScratchTokens() ); |
216 | } |
217 | |
218 | /** |
219 | * Formats a key or recovery code by creating groups of 4 separated by space characters |
220 | * |
221 | * @param string $token Token to format |
222 | * @return string The token formatted for display |
223 | */ |
224 | private function tokenFormatterFunction( $token ) { |
225 | return implode( ' ', str_split( $token, 4 ) ); |
226 | } |
227 | |
228 | /** |
229 | * @param array $formData |
230 | * @return array|bool |
231 | * @throws ConfigException |
232 | * @throws MWException |
233 | */ |
234 | public function onSubmit( array $formData ) { |
235 | $keyData = $this->getRequest()->getSessionData( 'oathauth_totp_key' ) ?? []; |
236 | $key = TOTPKey::newFromArray( $keyData ); |
237 | if ( !$key instanceof TOTPKey ) { |
238 | return [ 'oathauth-invalidrequest' ]; |
239 | } |
240 | |
241 | if ( $key->isScratchToken( $formData['token'] ) ) { |
242 | // A recovery code is not allowed for enrollment |
243 | LoggerFactory::getInstance( 'authentication' )->info( |
244 | 'OATHAuth {user} attempted to enable 2FA using a recovery code from {clientip}', [ |
245 | 'user' => $this->getUser()->getName(), |
246 | 'clientip' => $this->getRequest()->getIP(), |
247 | ] |
248 | ); |
249 | return [ 'oathauth-noscratchforvalidation' ]; |
250 | } |
251 | if ( !$key->verify( [ 'token' => $formData['token'] ], $this->oathUser ) ) { |
252 | LoggerFactory::getInstance( 'authentication' )->info( |
253 | 'OATHAuth {user} failed to provide a correct token while enabling 2FA from {clientip}', [ |
254 | 'user' => $this->getUser()->getName(), |
255 | 'clientip' => $this->getRequest()->getIP(), |
256 | ] |
257 | ); |
258 | return [ 'oathauth-failedtovalidateoath' ]; |
259 | } |
260 | |
261 | $this->getRequest()->setSessionData( 'oathauth_totp_key', null ); |
262 | $this->oathRepo->createKey( |
263 | $this->oathUser, |
264 | $this->module, |
265 | $key->jsonSerialize(), |
266 | $this->getRequest()->getIP() |
267 | ); |
268 | |
269 | return true; |
270 | } |
271 | } |