@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 PhabricatorAuthChallenge
4 extends PhabricatorAuthDAO
5 implements PhabricatorPolicyInterface {
6
7 protected $userPHID;
8 protected $factorPHID;
9 protected $sessionPHID;
10 protected $workflowKey;
11 protected $challengeKey;
12 protected $challengeTTL;
13 protected $responseDigest;
14 protected $responseTTL;
15 protected $isCompleted;
16 protected $properties = array();
17
18 private $responseToken;
19 private $isNewChallenge;
20
21 const HTTPKEY = '__hisec.challenges__';
22 const TOKEN_DIGEST_KEY = 'auth.challenge.token';
23
24 public static function initializeNewChallenge() {
25 return id(new self())
26 ->setIsCompleted(0);
27 }
28
29 public static function newHTTPParametersFromChallenges(array $challenges) {
30 assert_instances_of($challenges, self::class);
31
32 $token_list = array();
33 foreach ($challenges as $challenge) {
34 $token = $challenge->getResponseToken();
35 if ($token) {
36 $token_list[] = sprintf(
37 '%s:%s',
38 $challenge->getPHID(),
39 $token->openEnvelope());
40 }
41 }
42
43 if (!$token_list) {
44 return array();
45 }
46
47 $token_list = implode(' ', $token_list);
48
49 return array(
50 self::HTTPKEY => $token_list,
51 );
52 }
53
54 public static function newChallengeResponsesFromRequest(
55 array $challenges,
56 AphrontRequest $request) {
57 assert_instances_of($challenges, self::class);
58
59 $token_list = $request->getStr(self::HTTPKEY, '');
60 $token_list = explode(' ', $token_list);
61
62 $token_map = array();
63 foreach ($token_list as $token_element) {
64 $token_element = trim($token_element, ' ');
65
66 if (!strlen($token_element)) {
67 continue;
68 }
69
70 // NOTE: This error message is intentionally not printing the token to
71 // avoid disclosing it. As a result, it isn't terribly useful, but no
72 // normal user should ever end up here.
73 if (!preg_match('/^[^:]+:/', $token_element)) {
74 throw new Exception(
75 pht(
76 'This request included an improperly formatted MFA challenge '.
77 'token and can not be processed.'));
78 }
79
80 list($phid, $token) = explode(':', $token_element, 2);
81
82 if (isset($token_map[$phid])) {
83 throw new Exception(
84 pht(
85 'This request improperly specifies an MFA challenge token ("%s") '.
86 'multiple times and can not be processed.',
87 $phid));
88 }
89
90 $token_map[$phid] = new PhutilOpaqueEnvelope($token);
91 }
92
93 $challenges = mpull($challenges, null, 'getPHID');
94
95 $now = PhabricatorTime::getNow();
96 foreach ($challenges as $challenge_phid => $challenge) {
97 // If the response window has expired, don't attach the token.
98 if ($challenge->getResponseTTL() < $now) {
99 continue;
100 }
101
102 $token = idx($token_map, $challenge_phid);
103 if (!$token) {
104 continue;
105 }
106
107 $challenge->setResponseToken($token);
108 }
109 }
110
111
112 protected function getConfiguration() {
113 return array(
114 self::CONFIG_SERIALIZATION => array(
115 'properties' => self::SERIALIZATION_JSON,
116 ),
117 self::CONFIG_AUX_PHID => true,
118 self::CONFIG_COLUMN_SCHEMA => array(
119 'challengeKey' => 'text255',
120 'challengeTTL' => 'epoch',
121 'workflowKey' => 'text255',
122 'responseDigest' => 'text255?',
123 'responseTTL' => 'epoch?',
124 'isCompleted' => 'bool',
125 ),
126 self::CONFIG_KEY_SCHEMA => array(
127 'key_issued' => array(
128 'columns' => array('userPHID', 'challengeTTL'),
129 ),
130 'key_collection' => array(
131 'columns' => array('challengeTTL'),
132 ),
133 ),
134 ) + parent::getConfiguration();
135 }
136
137 public function getPHIDType() {
138 return PhabricatorAuthChallengePHIDType::TYPECONST;
139 }
140
141 public function getIsReusedChallenge() {
142 if ($this->getIsCompleted()) {
143 return true;
144 }
145
146 if (!$this->getIsAnsweredChallenge()) {
147 return false;
148 }
149
150 // If the challenge has been answered but the client has provided a token
151 // proving that they answered it, this is still a valid response.
152 if ($this->getResponseToken()) {
153 return false;
154 }
155
156 return true;
157 }
158
159 public function getIsAnsweredChallenge() {
160 return (bool)$this->getResponseDigest();
161 }
162
163 public function markChallengeAsAnswered($ttl) {
164 $token = Filesystem::readRandomCharacters(32);
165 $token = new PhutilOpaqueEnvelope($token);
166
167 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
168
169 $this
170 ->setResponseToken($token)
171 ->setResponseTTL($ttl)
172 ->save();
173
174 unset($unguarded);
175
176 return $this;
177 }
178
179 public function markChallengeAsCompleted() {
180 return $this
181 ->setIsCompleted(true)
182 ->save();
183 }
184
185 public function setResponseToken(PhutilOpaqueEnvelope $token) {
186 if (!$this->getUserPHID()) {
187 throw new PhutilInvalidStateException('setUserPHID');
188 }
189
190 if ($this->responseToken) {
191 throw new Exception(
192 pht(
193 'This challenge already has a response token; you can not '.
194 'set a new response token.'));
195 }
196
197 if (preg_match('/ /', $token->openEnvelope())) {
198 throw new Exception(
199 pht(
200 'The response token for this challenge is invalid: response '.
201 'tokens may not include spaces.'));
202 }
203
204 $digest = PhabricatorHash::digestWithNamedKey(
205 $token->openEnvelope(),
206 self::TOKEN_DIGEST_KEY);
207
208 if ($this->responseDigest !== null) {
209 if (!phutil_hashes_are_identical($digest, $this->responseDigest)) {
210 throw new Exception(
211 pht(
212 'Invalid response token for this challenge: token digest does '.
213 'not match stored digest.'));
214 }
215 } else {
216 $this->responseDigest = $digest;
217 }
218
219 $this->responseToken = $token;
220
221 return $this;
222 }
223
224 public function getResponseToken() {
225 return $this->responseToken;
226 }
227
228 public function setResponseDigest($value) {
229 throw new Exception(
230 pht(
231 'You can not set the response digest for a challenge directly. '.
232 'Instead, set a response token. A response digest will be computed '.
233 'automatically.'));
234 }
235
236 public function setProperty($key, $value) {
237 $this->properties[$key] = $value;
238 return $this;
239 }
240
241 public function getProperty($key, $default = null) {
242 return $this->properties[$key];
243 }
244
245 public function setIsNewChallenge($is_new_challenge) {
246 $this->isNewChallenge = $is_new_challenge;
247 return $this;
248 }
249
250 public function getIsNewChallenge() {
251 return $this->isNewChallenge;
252 }
253
254
255/* -( PhabricatorPolicyInterface )----------------------------------------- */
256
257
258 public function getCapabilities() {
259 return array(
260 PhabricatorPolicyCapability::CAN_VIEW,
261 );
262 }
263
264 public function getPolicy($capability) {
265 return PhabricatorPolicies::POLICY_NOONE;
266 }
267
268 public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
269 return ($viewer->getPHID() === $this->getUserPHID());
270 }
271
272}