Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TOTPEnableForm
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 7
132
0.00% covered (danger)
0.00%
0 / 1
 getHTML
0.00% covered (danger)
0.00%
0 / 5
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 / 78
0.00% covered (danger)
0.00%
0 / 1
2
 getRecoveryKeysFromSessionOrDefault
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 generateAltStep2Content
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
 onSubmit
0.00% covered (danger)
0.00%
0 / 32
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;
8use Endroid\QrCode\RoundBlockSizeMode;
9use Endroid\QrCode\Writer\SvgWriter;
10use MediaWiki\Extension\OATHAuth\Key\RecoveryCodeKeys;
11use MediaWiki\Extension\OATHAuth\Key\TOTPKey;
12use MediaWiki\Extension\OATHAuth\Module\RecoveryCodes;
13use MediaWiki\Html\Html;
14use MediaWiki\Logger\LoggerFactory;
15use MediaWiki\Status\Status;
16use OOUI\FieldLayout;
17use OOUI\HtmlSnippet;
18use OOUI\Widget;
19
20class TOTPEnableForm extends OATHAuthOOUIHTMLForm {
21
22    use KeySessionStorageTrait;
23    use RecoveryCodesTrait;
24
25    /** @inheritDoc */
26    public function getHTML( $submitResult ) {
27        $out = $this->getOutput();
28        $out->addModuleStyles( 'ext.oath.totpenable.styles' );
29        $out->addModuleStyles( 'ext.oath.recovery.styles' );
30        $out->addModules( 'ext.oath.recovery' );
31        return parent::getHTML( $submitResult );
32    }
33
34    /**
35     * Add content to output when the operation was successful
36     */
37    public function onSuccess(): void {
38        $this->getOutput()->addWikiMsg( 'oathauth-validatedoath' );
39    }
40
41    protected function getDescriptors(): array {
42        /** @var TOTPKey $key */
43        $key = $this->setKeyDataInSession( 'TOTPKey' );
44        $secret = $key->getSecret();
45        $issuer = $this->oathUser->getIssuer();
46        $account = $this->oathUser->getAccount();
47        $label = "{$issuer}:{$account}";
48        $qrcodeUrl = "otpauth://totp/"
49            . rawurlencode( $label )
50            . "?secret="
51            . rawurlencode( $secret )
52            . "&issuer="
53            . rawurlencode( $issuer );
54
55        $qrCode = ( new Builder(
56            writer: new SvgWriter(),
57            writerOptions: [ SvgWriter::WRITER_OPTION_EXCLUDE_XML_DECLARATION => true ],
58            data: $qrcodeUrl,
59            encoding: new Encoding( 'UTF-8' ),
60            errorCorrectionLevel: ErrorCorrectionLevel::High,
61            size: 256,
62            margin: 0,
63            roundBlockSizeMode: RoundBlockSizeMode::None,
64        ) )->build();
65
66        // messages used: oathauth-step1, oathauth-step-friendly-name oathauth-step2, oathauth-step3, oathauth-step4
67        return [
68            'app' => [
69                'type' => 'info',
70                'default' => $this->msg( 'oathauth-step1-test' )->parse(),
71                'raw' => true,
72                'section' => 'step1',
73            ],
74            'friendly_name' => [
75                'type' => 'text',
76                'default' => '',
77                'label-message' => 'oathauth-step-friendly-name-text',
78                'name' => 'friendly-name',
79                'section' => 'step-friendly-name',
80            ],
81            'qrcode' => [
82                'type' => 'info',
83                'default' =>
84                    $this->msg( 'oathauth-step2-qrcode' )->escaped() . '<br/>'
85                    . Html::element( 'img', [
86                        'class' => 'mw-oath-qrcode',
87                        'src' => $qrCode->getDataUri(),
88                        'alt' => $this->msg( 'oathauth-qrcode-alt' )->text(),
89                        'width' => 256,
90                        'height' => 256,
91                    ] ),
92                'raw' => true,
93                'section' => 'step2',
94            ],
95            'manual' => [
96                'type' => 'info',
97                'default' => $this->generateAltStep2Content( $key, $label ),
98                'raw' => true,
99                // We need to use a "rawrow" to prevent being wrapped by a label element.
100                'rawrow' => true,
101                'section' => 'step2',
102            ],
103            'recoverycodes' => [
104                'type' => 'info',
105                'default' =>
106                    $this->generateRecoveryCodesContent(
107                        $this->getRecoveryCodesForDisplay( $this->getRecoveryKeysFromSessionOrDefault() ),
108                        true
109                    ),
110                'raw' => true,
111                // We need to use a "rawrow" to prevent being wrapped by a label element.
112                'rawrow' => true,
113                'section' => 'step3',
114            ],
115            'token' => [
116                'type' => 'text',
117                'default' => '',
118                'label-message' => 'oathauth-entertoken',
119                'name' => 'token',
120                'section' => 'step4',
121                'dir' => 'ltr',
122                'autocomplete' => 'one-time-code',
123                'spellcheck' => false,
124            ]
125        ];
126    }
127
128    private function getRecoveryKeysFromSessionOrDefault(): RecoveryCodeKeys {
129        $keyDataRecCodes = $this->getKeyDataInSession( 'RecoveryCodeKeys' );
130        if ( $keyDataRecCodes ) {
131            return RecoveryCodeKeys::newFromArray( $keyDataRecCodes );
132        }
133
134        $keyDataRecCodes = [ 'recoverycodekeys' => [] ];
135        return $this->setKeyDataInSession(
136            'RecoveryCodeKeys',
137            $keyDataRecCodes
138        );
139    }
140
141    private function generateAltStep2Content( TOTPKey $key, string $label ): FieldLayout {
142        $snippet = new HtmlSnippet( '<p>'
143            . $this->msg( 'oathauth-step2alt' )->escaped() . '</p>'
144            . '<strong>' . $this->msg( 'oathauth-secret' )->escaped() . '</strong><br>'
145            . '<kbd>' . $this->getSecretForDisplay( $key ) . '</kbd></p>'
146            . '<p><strong>' . $this->msg( 'oathauth-account' )->escaped() . '</strong><br>'
147            . htmlspecialchars( $label ) . '</p>' );
148        // rawrow only accepts fieldlayouts
149        return new FieldLayout( new Widget( [ 'content' => $snippet ] ) );
150    }
151
152    /**
153     * Retrieve the current secret for display purposes
154     *
155     * The characters of the token are split in groups of 4
156     */
157    protected function getSecretForDisplay( TOTPKey $key ): string {
158        return $this->tokenFormatterFunction( $key->getSecret() );
159    }
160
161    public function onSubmit( array $formData ): Status|bool|array|string {
162        $keyData = $this->getKeyDataInSession( 'TOTPKey' );
163        $keyData['friendly_name'] = $formData["friendly_name"];
164        $TOTPkey = TOTPKey::newFromArray( $keyData );
165        if ( !$TOTPkey instanceof TOTPKey ) {
166            return [ 'oathauth-invalidrequest' ];
167        }
168
169        if ( $this->getRecoveryKeysFromSessionOrDefault()->isValidRecoveryCode( $formData['token'] ) ) {
170            // A recovery code is not allowed for enrollment
171            LoggerFactory::getInstance( 'authentication' )->info(
172                'OATHAuth {user} attempted to enable 2FA using a recovery code from {clientip}', [
173                    'user' => $this->getUser()->getName(),
174                    'clientip' => $this->getRequest()->getIP(),
175                ]
176            );
177            return [ 'oathauth-noscratchforvalidation' ];
178        }
179        if ( !$TOTPkey->verify( $this->oathUser, [ 'token' => $formData['token'] ] ) ) {
180            LoggerFactory::getInstance( 'authentication' )->info(
181                'OATHAuth {user} failed to provide a correct token while enabling 2FA from {clientip}', [
182                    'user' => $this->getUser()->getName(),
183                    'clientip' => $this->getRequest()->getIP(),
184                ]
185            );
186            return [ 'oathauth-failedtovalidateoath' ];
187        }
188
189        // Create recovery codes if needed, using the same codes that we displayed to the user
190        /** @var RecoveryCodes $recoveryCodesModule */
191        $recoveryCodesModule = $this->moduleRegistry->getModuleByKey( RecoveryCodes::MODULE_NAME );
192        '@phan-var RecoveryCodes $recoveryCodesModule';
193        $recoveryCodesModule->ensureExistence( $this->oathUser, $this->getKeyDataInSession( 'RecoveryCodeKeys' ) );
194
195        // Store the new TOTP key
196        $this->oathRepo->createKey(
197            $this->oathUser,
198            $this->module,
199            $TOTPkey->jsonSerialize(),
200            $this->getRequest()->getIP()
201        );
202
203        $this->setKeyDataInSessionToNull( 'TOTPKey' );
204        $this->setKeyDataInSessionToNull( 'RecoveryCodeKeys' );
205
206        return true;
207    }
208}