Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.74% covered (danger)
4.74%
9 / 190
0.00% covered (danger)
0.00%
0 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
OATHManage
4.74% covered (danger)
4.74%
9 / 190
0.00% covered (danger)
0.00%
0 / 27
4937.92
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
8.64
 checkPermissions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
56
 setAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setModule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 addEnabledHTML
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addAlternativesHTML
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 nothingEnabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addInactiveHTML
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 addGeneralHelp
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 addModuleHTML
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getGenericContent
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 addCustomContent
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 addHeading
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldShowGenericButtons
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isModuleRequested
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isModuleEnabled
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isModuleAvailable
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 isValidFormType
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 ensureRequiredFormFields
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 clearPage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 isGenericAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasAlternativeModules
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 shouldShowDisableWarning
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 showDisableWarning
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
30
 isSwitch
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3/**
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 2 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License along
15 * with this program; if not, write to the Free Software Foundation, Inc.,
16 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 * http://www.gnu.org/copyleft/gpl.html
18 */
19
20namespace MediaWiki\Extension\OATHAuth\Special;
21
22use MediaWiki\Config\ConfigException;
23use MediaWiki\Extension\OATHAuth\HTMLForm\IManageForm;
24use MediaWiki\Extension\OATHAuth\IModule;
25use MediaWiki\Extension\OATHAuth\OATHAuthModuleRegistry;
26use MediaWiki\Extension\OATHAuth\OATHUser;
27use MediaWiki\Extension\OATHAuth\OATHUserRepository;
28use MediaWiki\Html\Html;
29use MediaWiki\HTMLForm\HTMLForm;
30use MediaWiki\Message\Message;
31use MediaWiki\SpecialPage\SpecialPage;
32use MWException;
33use OOUI\ButtonWidget;
34use OOUI\HorizontalLayout;
35use OOUI\HtmlSnippet;
36use OOUI\LabelWidget;
37use OOUI\PanelLayout;
38use PermissionsError;
39use UserNotLoggedIn;
40
41class OATHManage extends SpecialPage {
42    public const ACTION_ENABLE = 'enable';
43    public const ACTION_DISABLE = 'disable';
44
45    protected OATHAuthModuleRegistry $moduleRegistry;
46
47    protected OATHUserRepository $userRepo;
48
49    protected OATHUser $authUser;
50
51    /**
52     * @var string
53     */
54    protected $action;
55
56    protected ?IModule $requestedModule;
57
58    /**
59     * Initializes a page to manage available 2FA modules
60     *
61     * @param OATHUserRepository $userRepo
62     * @param OATHAuthModuleRegistry $moduleRegistry
63     *
64     * @throws ConfigException
65     * @throws MWException
66     */
67    public function __construct( OATHUserRepository $userRepo, OATHAuthModuleRegistry $moduleRegistry ) {
68        // messages used: oathmanage (display "name" on Special:SpecialPages),
69        // right-oathauth-enable, action-oathauth-enable
70        parent::__construct( 'OATHManage', 'oathauth-enable' );
71
72        $this->userRepo = $userRepo;
73        $this->moduleRegistry = $moduleRegistry;
74        $this->authUser = $this->userRepo->findByUser( $this->getUser() );
75    }
76
77    /**
78     * @inheritDoc
79     */
80    protected function getGroupName() {
81        return 'login';
82    }
83
84    /**
85     * @param null|string $subPage
86     */
87    public function execute( $subPage ) {
88        $this->getOutput()->enableOOUI();
89        $this->getOutput()->disallowUserJs();
90        $this->setAction();
91        $this->setModule();
92
93        parent::execute( $subPage );
94
95        if ( $this->requestedModule instanceof IModule ) {
96            // Performing an action on a requested module
97            $this->clearPage();
98            if ( $this->shouldShowDisableWarning() ) {
99                $this->showDisableWarning();
100                return;
101            }
102            $this->addModuleHTML( $this->requestedModule );
103            return;
104        }
105
106        $this->addGeneralHelp();
107        if ( $this->authUser->isTwoFactorAuthEnabled() ) {
108            $this->addEnabledHTML();
109            if ( $this->hasAlternativeModules() ) {
110                $this->addAlternativesHTML();
111            }
112            return;
113        }
114        $this->nothingEnabled();
115    }
116
117    /**
118     * @throws PermissionsError
119     * @throws UserNotLoggedIn
120     */
121    public function checkPermissions() {
122        $this->requireNamedUser();
123
124        $canEnable = $this->getUser()->isAllowed( 'oathauth-enable' );
125
126        if ( $this->action === static::ACTION_ENABLE && !$canEnable ) {
127            $this->displayRestrictionError();
128        }
129
130        if ( !$this->authUser->isTwoFactorAuthEnabled() && !$canEnable ) {
131            // No enabled module and cannot enable - nothing to do
132            $this->displayRestrictionError();
133        }
134
135        if ( $this->action === static::ACTION_ENABLE && !$this->getRequest()->wasPosted() ) {
136            // Trying to change the 2FA method (one is already enabled)
137            $this->checkLoginSecurityLevel( 'oathauth-enable' );
138        }
139    }
140
141    private function setAction(): void {
142        $this->action = $this->getRequest()->getVal( 'action', '' );
143    }
144
145    private function setModule(): void {
146        $moduleKey = $this->getRequest()->getVal( 'module', '' );
147        $this->requestedModule = ( $moduleKey && $this->moduleRegistry->moduleExists( $moduleKey ) )
148            ? $this->moduleRegistry->getModuleByKey( $moduleKey )
149            : null;
150    }
151
152    private function addEnabledHTML(): void {
153        $this->addHeading( $this->msg( 'oathauth-ui-enabled-module' ) );
154        $this->addModuleHTML( $this->authUser->getModule() );
155    }
156
157    private function addAlternativesHTML(): void {
158        $this->addHeading( $this->msg( 'oathauth-ui-not-enabled-modules' ) );
159        $this->addInactiveHTML();
160    }
161
162    private function nothingEnabled(): void {
163        $this->addHeading( $this->msg( 'oathauth-ui-available-modules' ) );
164        $this->addInactiveHTML();
165    }
166
167    private function addInactiveHTML(): void {
168        foreach ( $this->moduleRegistry->getAllModules() as $module ) {
169            if ( $this->isModuleEnabled( $module ) || !$this->isModuleAvailable( $module ) ) {
170                continue;
171            }
172            $this->addModuleHTML( $module );
173        }
174    }
175
176    private function addGeneralHelp(): void {
177        $this->getOutput()->addHTML( $this->msg(
178            'oathauth-ui-general-help'
179        )->parseAsBlock() );
180    }
181
182    private function addModuleHTML( ?IModule $module ): void {
183        if ( $module instanceof IModule && $this->isModuleRequested( $module ) ) {
184            $this->addCustomContent( $module );
185            return;
186        }
187
188        $panel = $this->getGenericContent( $module );
189        if ( $module instanceof IModule && $this->isModuleEnabled( $module ) ) {
190            $this->addCustomContent( $module, $panel );
191        }
192
193        $this->getOutput()->addHTML( (string)$panel );
194    }
195
196    /**
197     * Get the panel with generic content for a module
198     */
199    private function getGenericContent( ?IModule $module ): PanelLayout {
200        $modulePanel = new PanelLayout( [
201            'framed' => true,
202            'expanded' => false,
203            'padded' => true
204        ] );
205        $headerLayout = new HorizontalLayout();
206
207        $label = new LabelWidget( [
208            'label' => $module->getDisplayName()->text()
209        ] );
210        if ( $this->shouldShowGenericButtons() ) {
211            $enabled = $module && $this->isModuleEnabled( $module );
212            $button = new ButtonWidget( [
213                'label' => $this
214                    ->msg( $enabled ? 'oathauth-disable-generic' : 'oathauth-enable-generic' )
215                    ->text(),
216                'href' => $this->getOutput()->getTitle()->getLocalURL( [
217                    'action' => $enabled ? static::ACTION_DISABLE : static::ACTION_ENABLE,
218                    'module' => $module->getName(),
219                    'warn' => 1
220                ] )
221            ] );
222            $headerLayout->addItems( [ $button ] );
223        }
224        $headerLayout->addItems( [ $label ] );
225
226        $modulePanel->appendContent( $headerLayout );
227        $modulePanel->appendContent( new HtmlSnippet(
228            $module->getDescriptionMessage()->parseAsBlock()
229        ) );
230        return $modulePanel;
231    }
232
233    private function addCustomContent( IModule $module, ?PanelLayout $panel = null ): void {
234        $form = $module->getManageForm(
235            $this->action,
236            $this->authUser,
237            $this->userRepo,
238            $this->getContext()
239        );
240        if ( $form === null || !$this->isValidFormType( $form ) ) {
241            return;
242        }
243        $form->setTitle( $this->getOutput()->getTitle() );
244        $this->ensureRequiredFormFields( $form, $module );
245        $form->setSubmitCallback( [ $form, 'onSubmit' ] );
246        if ( $form->show( $panel ) ) {
247            $form->onSuccess();
248        }
249    }
250
251    private function addHeading( Message $message ): void {
252        $this->getOutput()->addHTML( Html::element( 'h2', [], $message->text() ) );
253    }
254
255    private function shouldShowGenericButtons(): bool {
256        return !$this->requestedModule instanceof IModule || !$this->isGenericAction();
257    }
258
259    private function isModuleRequested( ?IModule $module ): bool {
260        return (
261            $this->requestedModule instanceof IModule
262            && $module instanceof IModule
263            && $this->requestedModule->getName() === $module->getName()
264        );
265    }
266
267    private function isModuleEnabled( IModule $module ): bool {
268        $enabled = $this->authUser->getModule();
269        if ( !$enabled ) {
270            return false;
271        }
272        return $enabled->getName() === $module->getName();
273    }
274
275    /**
276     * Verifies if the module is available to be enabled
277     *
278     * @param IModule $module
279     * @return bool
280     */
281    private function isModuleAvailable( IModule $module ): bool {
282        $form = $module->getManageForm(
283            static::ACTION_ENABLE,
284            $this->authUser,
285            $this->userRepo,
286            $this->getContext()
287        );
288        if ( $form === '' ) {
289            return false;
290        }
291        return true;
292    }
293
294    /**
295     * Verifies if the given form instance fulfills the required conditions
296     *
297     * @param mixed $form
298     * @return bool
299     */
300    private function isValidFormType( $form ): bool {
301        if ( !( $form instanceof HTMLForm ) ) {
302            return false;
303        }
304        $implements = class_implements( $form );
305        if ( !isset( $implements[IManageForm::class] ) ) {
306            return false;
307        }
308
309        return true;
310    }
311
312    private function ensureRequiredFormFields( IManageForm $form, IModule $module ): void {
313        if ( !$form->hasField( 'module' ) ) {
314            $form->addHiddenField( 'module', $module->getName() );
315        }
316        if ( !$form->hasField( 'action' ) ) {
317            $form->addHiddenField( 'action', $this->action );
318        }
319    }
320
321    /**
322     * When performing an action on a module (like enable/disable),
323     * page should contain only the form for that action.
324     */
325    private function clearPage(): void {
326        if ( $this->isGenericAction() ) {
327            $displayName = $this->requestedModule->getDisplayName();
328            $pageTitleMessage = $this->isModuleEnabled( $this->requestedModule ) ?
329                $this->msg( 'oathauth-disable-page-title', $displayName ) :
330                $this->msg( 'oathauth-enable-page-title', $displayName );
331            $this->getOutput()->setPageTitleMsg( $pageTitleMessage );
332        }
333
334        $this->getOutput()->clearHTML();
335        $this->getOutput()->addBacklinkSubtitle( $this->getOutput()->getTitle() );
336    }
337
338    /**
339     * The enable and disable actions are generic, and all modules must
340     * implement them, while all other actions are module-specific.
341     */
342    private function isGenericAction(): bool {
343        return in_array( $this->action, [ static::ACTION_ENABLE, static::ACTION_DISABLE ] );
344    }
345
346    private function hasAlternativeModules(): bool {
347        foreach ( $this->moduleRegistry->getAllModules() as $module ) {
348            if ( !$this->isModuleEnabled( $module ) && $this->isModuleAvailable( $module ) ) {
349                return true;
350            }
351        }
352        return false;
353    }
354
355    private function shouldShowDisableWarning(): bool {
356        return $this->getRequest()->getBool( 'warn' ) &&
357            $this->requestedModule instanceof IModule &&
358            $this->authUser->isTwoFactorAuthEnabled();
359    }
360
361    private function showDisableWarning(): void {
362        $panel = new PanelLayout( [
363            'padded' => true,
364            'framed' => true,
365            'expanded' => false
366        ] );
367
368        $isSwitch = $this->isSwitch();
369        $currentDisplayName = $this->authUser->getModule()->getDisplayName();
370        $newDisplayName = $this->requestedModule->getDisplayName();
371
372        $genericMessage = $isSwitch ?
373            $this->msg(
374                'oathauth-switch-method-warning',
375                $currentDisplayName,
376                $newDisplayName
377            ) :
378            $this->msg( 'oathauth-disable-method-warning', $currentDisplayName );
379
380        $panel->appendContent( new HtmlSnippet(
381            $genericMessage->parseAsBlock()
382        ) );
383
384        $customMessage = $this->authUser->getModule()->getDisableWarningMessage();
385        if ( $customMessage instanceof Message ) {
386            $panel->appendContent( new HtmlSnippet(
387                $customMessage->parseAsBlock()
388            ) );
389        }
390
391        $nextStepMessage = $isSwitch ?
392            $this->msg( 'oathauth-switch-method-next-step', $currentDisplayName ) :
393            $this->msg( 'oathauth-disable-method-next-step', $currentDisplayName, $newDisplayName );
394
395        $panel->appendContent( new HtmlSnippet(
396            $nextStepMessage->parseAsBlock()
397        ) );
398
399        $button = new ButtonWidget( [
400            'label' => $this->msg( 'oathauth-disable-method-warning-button-label' )->plain(),
401            'href' => $this->getOutput()->getTitle()->getLocalURL( [
402                'action' => $this->action,
403                'module' => $this->requestedModule->getName()
404            ] ),
405            'flags' => [ 'primary', 'progressive' ]
406        ] );
407        $panel->appendContent( $button );
408
409        $headerMessage = $isSwitch ?
410            $this->msg( 'oathauth-switch-method-warning-header' ) :
411            $this->msg( 'oathauth-disable-method-warning-header' );
412
413        $this->getOutput()->setPageTitleMsg( $headerMessage );
414        $this->getOutput()->addHTML( $panel->toString() );
415    }
416
417    private function isSwitch(): bool {
418        return $this->requestedModule instanceof IModule &&
419            $this->action === static::ACTION_ENABLE &&
420            $this->authUser->isTwoFactorAuthEnabled();
421    }
422
423}