Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
29.63% covered (danger)
29.63%
16 / 54
7.14% covered (danger)
7.14%
1 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecoveryCodes
29.63% covered (danger)
29.63%
16 / 54
7.14% covered (danger)
7.14%
1 / 14
207.34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDisplayName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getSecondaryAuthProvider
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 verify
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
5.04
 ensureExistence
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 isEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getManageForm
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getDescriptionMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDisableWarningMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAddKeyMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLoginSwitchButtonMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isSpecial
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\OATHAuth\Module;
4
5use MediaWiki\Context\IContextSource;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\Extension\OATHAuth\Auth\RecoveryCodesSecondaryAuthenticationProvider;
8use MediaWiki\Extension\OATHAuth\HTMLForm\OATHAuthOOUIHTMLForm;
9use MediaWiki\Extension\OATHAuth\HTMLForm\RecoveryCodesRemoveTemporaryForm;
10use MediaWiki\Extension\OATHAuth\HTMLForm\RecoveryCodesStatusForm;
11use MediaWiki\Extension\OATHAuth\Key\RecoveryCodeKeys;
12use MediaWiki\Extension\OATHAuth\Notifications\Manager;
13use MediaWiki\Extension\OATHAuth\OATHAuthModuleRegistry;
14use MediaWiki\Extension\OATHAuth\OATHAuthServices;
15use MediaWiki\Extension\OATHAuth\OATHUser;
16use MediaWiki\Extension\OATHAuth\OATHUserRepository;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Message\Message;
19use UnexpectedValueException;
20
21class RecoveryCodes implements IModule {
22    public const MODULE_NAME = "recoverycodes";
23
24    public const ACTION_REMOVE_TEMPORARY = "recoverycodes-temporary-remove";
25
26    /**
27     * Number of recovery code module instances allowed per user in oathauth_devices
28     */
29    public const RECOVERY_CODE_MODULE_COUNT = 1;
30
31    /** Threshold number of recovery codes to trigger notification */
32    private const RECOVERY_CODE_LEFT = 2;
33
34    public function __construct( private readonly OATHUserRepository $userRepository ) {
35    }
36
37    /** @inheritDoc */
38    public function getName(): string {
39        return self::MODULE_NAME;
40    }
41
42    /** @inheritDoc */
43    public function getDisplayName(): Message {
44        return wfMessage( 'oathauth-module-recoverycodes-label' );
45    }
46
47    /** @inheritDoc */
48    public function newKey( array $data ): RecoveryCodeKeys {
49        if ( !isset( $data['recoverycodekeys'] ) ) {
50            throw new UnexpectedValueException( 'oathauth-invalid-recovery-code-data-format' );
51        }
52        return RecoveryCodeKeys::newFromArray( $data );
53    }
54
55    public function getSecondaryAuthProvider(): RecoveryCodesSecondaryAuthenticationProvider {
56        return new RecoveryCodesSecondaryAuthenticationProvider(
57            $this,
58            $this->userRepository,
59            OATHAuthServices::getInstance()->getLogger()
60        );
61    }
62
63    public function verify( OATHUser $user, array $data ): bool {
64        if ( !isset( $data['recoverycode'] ) ) {
65            return false;
66        }
67
68        $recoveryCodeKeys = $user->getKeysForModule( self::MODULE_NAME );
69
70        if ( $recoveryCodeKeys === [] ) {
71            return false;
72        }
73
74        /** @var $recoveryCodeKey RecoveryCodeKeys */
75        $recoveryCodeKey = $recoveryCodeKeys[0];
76        '@phan-var RecoveryCodeKeys $recoveryCodeKey';
77
78        if ( !$recoveryCodeKey->verify( $user, $data ) ) {
79            return false;
80        }
81
82        // Remove the key that was used
83        $recoveryCodeKey->removeRecoveryCode( $user, $data['recoverycode'] );
84
85        if ( count( $recoveryCodeKey->getRecoveryCodes() ) <= self::RECOVERY_CODE_LEFT ) {
86            Manager::notifyRecoveryTokensRemaining(
87                $user,
88                count( $recoveryCodeKey->getRecoveryCodes() ),
89                MediaWikiServices::getInstance()->getMainConfig()->get( 'OATHRecoveryCodesCount' )
90            );
91        }
92
93        // Save the key removal to the database
94        $this->userRepository->updateKey( $user, $recoveryCodeKey );
95
96        return true;
97    }
98
99    /**
100     * Ensure that a RecoveryCodeKeys key exists for the given user, creating a new one if needed.
101     *
102     * @param OATHUser $user
103     * @param ?array $recoveryCodesData Use this data to create the new RecoveryCodesKey if needed
104     * @return RecoveryCodeKeys Pre-existing or newly created RecoveryCodeKeys key
105     */
106    public function ensureExistence( OATHUser $user, ?array $recoveryCodesData = null ): RecoveryCodeKeys {
107        $rcKeys = $user->getKeysForModule( self::MODULE_NAME );
108        if ( count( $rcKeys ) > self::RECOVERY_CODE_MODULE_COUNT ) {
109            throw new UnexpectedValueException( wfMessage( 'oathauth-recoverycodes-too-many-instances' ) );
110        }
111        $recoveryCodeKey = $rcKeys[ 0 ] ?? null;
112        if ( $recoveryCodeKey instanceof RecoveryCodeKeys ) {
113            // User already has recovery codes, nothing to do
114            return $recoveryCodeKey;
115        }
116
117        // Use the provided $recoveryCodesData if there is one, otherwise create an empty key
118        // and generate new codes
119        $recoveryCodeKey = $this->newKey( $recoveryCodesData ?? [ 'recoverycodekeys' => [] ] );
120        if ( $recoveryCodeKey->getRecoveryCodes() === [] ) {
121            $recoveryCodeKey->regenerateRecoveryCodeKeys();
122        }
123
124        // Save the new key to the database
125        $oathRepo = OATHAuthServices::getInstance()->getUserRepository();
126        /** @var RecoveryCodeKeys $newKey */
127        $newKey = $oathRepo->createKey(
128            $user,
129            $this,
130            $recoveryCodeKey->jsonSerialize(),
131            RequestContext::getMain()->getRequest()->getIP()
132        );
133        '@phan-var RecoveryCodeKeys $newKey';
134        return $newKey;
135    }
136
137    /**
138     * Is this module currently enabled for the given user?
139     */
140    public function isEnabled( OATHUser $user ): bool {
141        return $user->getKeysForModule( self::MODULE_NAME ) !== [];
142    }
143
144    /** @inheritDoc */
145    public function getManageForm(
146        string $action,
147        OATHUser $user,
148        OATHUserRepository $repo,
149        IContextSource $context,
150        OATHAuthModuleRegistry $registry
151    ): ?OATHAuthOOUIHTMLForm {
152        if ( $action === self::ACTION_REMOVE_TEMPORARY ) {
153            return new RecoveryCodesRemoveTemporaryForm( $user, $repo, $this, $context, $registry );
154        }
155        return new RecoveryCodesStatusForm( $user, $repo, $this, $context, $registry );
156    }
157
158    /** @inheritDoc */
159    public function getDescriptionMessage(): Message {
160        return wfMessage( 'oathauth-recoverycodes-description' );
161    }
162
163    /** @inheritDoc */
164    public function getDisableWarningMessage(): ?Message {
165        return null;
166    }
167
168    public function getAddKeyMessage(): ?Message {
169        return null;
170    }
171
172    public function getLoginSwitchButtonMessage(): Message {
173        return wfMessage( 'oathauth-auth-use-recovery-code' );
174    }
175
176    /** @inheritDoc */
177    public function isSpecial(): bool {
178        return true;
179    }
180}