Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 159
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
TOTPEnableForm
0.00% covered (danger)
0.00%
0 / 159
0.00% covered (danger)
0.00%
0 / 11
272
0.00% covered (danger)
0.00%
0 / 1
 getHTML
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onSuccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescriptors
0.00% covered (danger)
0.00%
0 / 93
0.00% covered (danger)
0.00%
0 / 1
6
 createResourceList
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 createTextList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createDownloadLink
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 createCopyButton
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getSecretForDisplay
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getScratchTokensForDisplay
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tokenFormatterFunction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onSubmit
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace MediaWiki\Extension\OATHAuth\HTMLForm;
4
5use Endroid\QrCode\Builder\Builder;
6use Endroid\QrCode\Encoding\Encoding;
7use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
8use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeNone;
9use Endroid\QrCode\Writer\SvgWriter;
10use MediaWiki\Config\ConfigException;
11use MediaWiki\Extension\OATHAuth\Key\TOTPKey;
12use MediaWiki\Html\Html;
13use MediaWiki\Logger\LoggerFactory;
14use MediaWiki\Status\Status;
15use MWException;
16
17class 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}