@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3final class PhabricatorSMSAuthFactor
4 extends PhabricatorAuthFactor {
5
6 public function getFactorKey() {
7 return 'sms';
8 }
9
10 public function getFactorName() {
11 return pht('Text Message (SMS)');
12 }
13
14 public function getFactorShortName() {
15 return pht('SMS');
16 }
17
18 public function getFactorCreateHelp() {
19 return pht(
20 'Allow users to receive a code via SMS.');
21 }
22
23 public function getFactorDescription() {
24 return pht(
25 'When you need to authenticate, a text message with a code will '.
26 'be sent to your phone.');
27 }
28
29 public function getFactorOrder() {
30 // Sort this factor toward the end of the list because SMS is relatively
31 // weak.
32 return 2000;
33 }
34
35 public function isContactNumberFactor() {
36 return true;
37 }
38
39 public function canCreateNewProvider() {
40 return $this->isSMSMailerConfigured();
41 }
42
43 public function getProviderCreateDescription() {
44 $messages = array();
45
46 if (!$this->isSMSMailerConfigured()) {
47 $messages[] = id(new PHUIInfoView())
48 ->setErrors(
49 array(
50 pht(
51 'You have not configured an outbound SMS mailer. You must '.
52 'configure one before you can set up SMS. See: %s',
53 phutil_tag(
54 'a',
55 array(
56 'href' => '/config/edit/cluster.mailers/',
57 ),
58 'cluster.mailers')),
59 ));
60 }
61
62 $messages[] = id(new PHUIInfoView())
63 ->setSeverity(PHUIInfoView::SEVERITY_WARNING)
64 ->setErrors(
65 array(
66 pht(
67 'SMS is weak, and relatively easy for attackers to compromise. '.
68 'Strongly consider using a different MFA provider.'),
69 ));
70
71 return $messages;
72 }
73
74 public function canCreateNewConfiguration(
75 PhabricatorAuthFactorProvider $provider,
76 PhabricatorUser $user) {
77
78 if (!$this->loadUserContactNumber($user)) {
79 return false;
80 }
81
82 if ($this->loadConfigurationsForProvider($provider, $user)) {
83 return false;
84 }
85
86 return true;
87 }
88
89 public function getConfigurationCreateDescription(
90 PhabricatorAuthFactorProvider $provider,
91 PhabricatorUser $user) {
92
93 $messages = array();
94
95 if (!$this->loadUserContactNumber($user)) {
96 $messages[] = id(new PHUIInfoView())
97 ->setSeverity(PHUIInfoView::SEVERITY_WARNING)
98 ->setErrors(
99 array(
100 pht(
101 'You have not configured a primary contact number. Configure '.
102 'a contact number before adding SMS as an authentication '.
103 'factor.'),
104 ));
105 }
106
107 if ($this->loadConfigurationsForProvider($provider, $user)) {
108 $messages[] = id(new PHUIInfoView())
109 ->setSeverity(PHUIInfoView::SEVERITY_WARNING)
110 ->setErrors(
111 array(
112 pht(
113 'You already have SMS authentication attached to your account.'),
114 ));
115 }
116
117 return $messages;
118 }
119
120 public function getEnrollDescription(
121 PhabricatorAuthFactorProvider $provider,
122 PhabricatorUser $user) {
123 return pht(
124 'To verify your phone as an authentication factor, a text message with '.
125 'a secret code will be sent to the phone number you have listed as '.
126 'your primary contact number.');
127 }
128
129 public function getEnrollButtonText(
130 PhabricatorAuthFactorProvider $provider,
131 PhabricatorUser $user) {
132 $contact_number = $this->loadUserContactNumber($user);
133
134 return pht('Send SMS: %s', $contact_number->getDisplayName());
135 }
136
137 public function processAddFactorForm(
138 PhabricatorAuthFactorProvider $provider,
139 AphrontFormView $form,
140 AphrontRequest $request,
141 PhabricatorUser $user) {
142
143 $token = $this->loadMFASyncToken($provider, $request, $form, $user);
144 $code = $request->getStr('sms.code');
145
146 $e_code = true;
147 if (!$token->getIsNewTemporaryToken()) {
148 $expect_code = $token->getTemporaryTokenProperty('code');
149
150 $okay = phutil_hashes_are_identical(
151 $this->normalizeSMSCode($code),
152 $this->normalizeSMSCode($expect_code));
153
154 if ($okay) {
155 $config = $this->newConfigForUser($user)
156 ->setFactorName(pht('SMS'));
157
158 return $config;
159 } else {
160 if (!strlen($code)) {
161 $e_code = pht('Required');
162 } else {
163 $e_code = pht('Invalid');
164 }
165 }
166 }
167
168 $form->appendRemarkupInstructions(
169 pht(
170 'Enter the code from the text message which was sent to your '.
171 'primary contact number.'));
172
173 $form->appendChild(
174 id(new PHUIFormNumberControl())
175 ->setLabel(pht('SMS Code'))
176 ->setName('sms.code')
177 ->setValue($code)
178 ->setError($e_code));
179 }
180
181 protected function newIssuedChallenges(
182 PhabricatorAuthFactorConfig $config,
183 PhabricatorUser $viewer,
184 array $challenges) {
185
186 // If we already issued a valid challenge for this workflow and session,
187 // don't issue a new one.
188
189 $challenge = $this->getChallengeForCurrentContext(
190 $config,
191 $viewer,
192 $challenges);
193 if ($challenge) {
194 return array();
195 }
196
197 if (!$this->loadUserContactNumber($viewer)) {
198 return $this->newResult()
199 ->setIsError(true)
200 ->setErrorMessage(
201 pht(
202 'Your account has no primary contact number.'));
203 }
204
205 if (!$this->isSMSMailerConfigured()) {
206 return $this->newResult()
207 ->setIsError(true)
208 ->setErrorMessage(
209 pht(
210 'No outbound mailer which can deliver SMS messages is '.
211 'configured.'));
212 }
213
214 if (!$this->hasCSRF($config)) {
215 return $this->newResult()
216 ->setIsContinue(true)
217 ->setErrorMessage(
218 pht(
219 'A text message with an authorization code will be sent to your '.
220 'primary contact number.'));
221 }
222
223 // Otherwise, issue a new challenge.
224
225 $challenge_code = $this->newSMSChallengeCode();
226 $envelope = new PhutilOpaqueEnvelope($challenge_code);
227 $this->sendSMSCodeToUser($envelope, $viewer);
228
229 $ttl_seconds = phutil_units('15 minutes in seconds');
230
231 return array(
232 $this->newChallenge($config, $viewer)
233 ->setChallengeKey($challenge_code)
234 ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds),
235 );
236 }
237
238 protected function newResultFromIssuedChallenges(
239 PhabricatorAuthFactorConfig $config,
240 PhabricatorUser $viewer,
241 array $challenges) {
242
243 $challenge = $this->getChallengeForCurrentContext(
244 $config,
245 $viewer,
246 $challenges);
247
248 if ($challenge->getIsAnsweredChallenge()) {
249 return $this->newResult()
250 ->setAnsweredChallenge($challenge);
251 }
252
253 return null;
254 }
255
256 public function renderValidateFactorForm(
257 PhabricatorAuthFactorConfig $config,
258 AphrontFormView $form,
259 PhabricatorUser $viewer,
260 PhabricatorAuthFactorResult $result) {
261
262 $control = $this->newAutomaticControl($result);
263 if (!$control) {
264 $value = $result->getValue();
265 $error = $result->getErrorMessage();
266 $name = $this->getChallengeResponseParameterName($config);
267
268 $control = id(new PHUIFormNumberControl())
269 ->setName($name)
270 ->setDisableAutocomplete(true)
271 ->setValue($value)
272 ->setError($error);
273 }
274
275 $control
276 ->setLabel(pht('SMS Code'))
277 ->setCaption(pht('Factor Name: %s', $config->getFactorName()));
278
279 $form->appendChild($control);
280 }
281
282 public function getRequestHasChallengeResponse(
283 PhabricatorAuthFactorConfig $config,
284 AphrontRequest $request) {
285 $value = $this->getChallengeResponseFromRequest($config, $request);
286 return (bool)strlen($value);
287 }
288
289 protected function newResultFromChallengeResponse(
290 PhabricatorAuthFactorConfig $config,
291 PhabricatorUser $viewer,
292 AphrontRequest $request,
293 array $challenges) {
294
295 $challenge = $this->getChallengeForCurrentContext(
296 $config,
297 $viewer,
298 $challenges);
299
300 $code = $this->getChallengeResponseFromRequest(
301 $config,
302 $request);
303
304 $result = $this->newResult()
305 ->setValue($code);
306
307 if ($challenge->getIsAnsweredChallenge()) {
308 return $result->setAnsweredChallenge($challenge);
309 }
310
311 if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) {
312 $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds');
313
314 $challenge
315 ->markChallengeAsAnswered($ttl);
316
317 return $result->setAnsweredChallenge($challenge);
318 }
319
320 if (strlen($code)) {
321 $error_message = pht('Invalid');
322 } else {
323 $error_message = pht('Required');
324 }
325
326 $result->setErrorMessage($error_message);
327
328 return $result;
329 }
330
331 private function newSMSChallengeCode() {
332 $value = Filesystem::readRandomInteger(0, 99999999);
333 $value = sprintf('%08d', $value);
334 return $value;
335 }
336
337 public function isSMSMailerConfigured() {
338 $mailers = PhabricatorMetaMTAMail::newMailers(
339 array(
340 'outbound' => true,
341 'media' => array(
342 PhabricatorMailSMSMessage::MESSAGETYPE,
343 ),
344 ));
345
346 return (bool)$mailers;
347 }
348
349 private function loadUserContactNumber(PhabricatorUser $user) {
350 $contact_numbers = id(new PhabricatorAuthContactNumberQuery())
351 ->setViewer($user)
352 ->withObjectPHIDs(array($user->getPHID()))
353 ->withStatuses(
354 array(
355 PhabricatorAuthContactNumber::STATUS_ACTIVE,
356 ))
357 ->withIsPrimary(true)
358 ->execute();
359
360 if (count($contact_numbers) !== 1) {
361 return null;
362 }
363
364 return head($contact_numbers);
365 }
366
367 protected function newMFASyncTokenProperties(
368 PhabricatorAuthFactorProvider $providerr,
369 PhabricatorUser $user) {
370
371 $sms_code = $this->newSMSChallengeCode();
372
373 $envelope = new PhutilOpaqueEnvelope($sms_code);
374 $this->sendSMSCodeToUser($envelope, $user);
375
376 return array(
377 'code' => $sms_code,
378 );
379 }
380
381 private function sendSMSCodeToUser(
382 PhutilOpaqueEnvelope $envelope,
383 PhabricatorUser $user) {
384 return id(new PhabricatorMetaMTAMail())
385 ->setMessageType(PhabricatorMailSMSMessage::MESSAGETYPE)
386 ->addTos(array($user->getPHID()))
387 ->setForceDelivery(true)
388 ->setSensitiveContent(true)
389 ->setBody(
390 pht(
391 '%s (%s) MFA Code: %s',
392 PlatformSymbols::getPlatformServerName(),
393 $this->getInstallDisplayName(),
394 $envelope->openEnvelope()))
395 ->save();
396 }
397
398 private function normalizeSMSCode($code) {
399 return trim($code);
400 }
401
402}