Merge lp:~cjwatson/launchpad/wsgi-ppa-auth into lp:launchpad
- wsgi-ppa-auth
- Merge into devel
Status: | Rejected |
---|---|
Rejected by: | Colin Watson |
Proposed branch: | lp:~cjwatson/launchpad/wsgi-ppa-auth |
Merge into: | lp:launchpad |
Prerequisite: | lp:~cjwatson/launchpad/virtualenv-pip |
Diff against target: |
1113 lines (+768/-71) 20 files modified
Makefile (+1/-0) configs/development/local-launchpad-apache (+30/-3) lib/lp/services/config/schema-lazr.conf (+5/-0) lib/lp/services/memcache/client.py (+13/-51) lib/lp/services/memcache/testing.py (+25/-4) lib/lp/services/memcache/timeline.py (+57/-0) lib/lp/soyuz/doc/archiveauthtoken.txt (+24/-1) lib/lp/soyuz/interfaces/archiveapi.py (+46/-0) lib/lp/soyuz/interfaces/archiveauthtoken.py (+16/-6) lib/lp/soyuz/model/archiveauthtoken.py (+25/-5) lib/lp/soyuz/wsgi/archiveauth.py (+83/-0) lib/lp/soyuz/wsgi/tests/test_archiveauth.py (+151/-0) lib/lp/soyuz/xmlrpc/archive.py (+62/-0) lib/lp/soyuz/xmlrpc/tests/test_archive.py (+124/-0) lib/lp/systemhomes.py (+7/-0) lib/lp/xmlrpc/application.py (+7/-1) lib/lp/xmlrpc/configure.zcml (+13/-0) lib/lp/xmlrpc/interfaces.py (+2/-0) scripts/wsgi-archive-auth.py (+71/-0) utilities/rocketfuel-setup (+6/-0) |
To merge this branch: | bzr merge lp:~cjwatson/launchpad/wsgi-ppa-auth |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Launchpad code reviewers | Pending | ||
Review via email: mp+332125@code.launchpad.net |
Commit message
Add a WSGI authenticator for private PPAs.
Description of the change
I got sufficiently annoyed in the process of fixing bug 1722209 to see how hard it would be to fix it properly, since the existing htpasswd scheme has been a thorn in our side for a while now. The answer appears to be "a bit, but not very".
This will let us remove our reliance on htpasswd files, and probably eventually make it easier to do things like issue time-limited tokens (or even macaroons?) to builders.
There are obviously various deployment issues to sort out, and generate-
The way that mod_wsgi loads the entry point is peculiar. I tried to handle it with the existing buildout-based build system, but that ended up being too fiddly, so I decided to just depend on my virtualenv/pip conversion branch. I considered separating the client code off entirely from Launchpad, but this turns out to be light enough in terms of memory use and startup time that I don't think it's worth the effort at the moment.
- 18475. By Colin Watson
-
Beef up MemcacheFixture a bit to support expiry times and to reject non-str keys.
- 18476. By Colin Watson
-
Separate out request-timeline handling so that memcache_
client_ factory can be used to create a basic client with minimal dependencies. - 18477. By Colin Watson
-
Use memcached instead of timedcache, storing hashed passwords.
- 18478. By Colin Watson
-
Merge virtualenv-pip.
Colin Watson (cjwatson) wrote : | # |
Colin Watson (cjwatson) wrote : | # |
Unmerged revisions
- 18478. By Colin Watson
-
Merge virtualenv-pip.
- 18477. By Colin Watson
-
Use memcached instead of timedcache, storing hashed passwords.
- 18476. By Colin Watson
-
Separate out request-timeline handling so that memcache_
client_ factory can be used to create a basic client with minimal dependencies. - 18475. By Colin Watson
-
Beef up MemcacheFixture a bit to support expiry times and to reject non-str keys.
- 18474. By Colin Watson
-
Add a WSGI authenticator for private PPAs.
Preview Diff
1 | === modified file 'Makefile' | |||
2 | --- Makefile 2017-11-19 12:35:56 +0000 | |||
3 | +++ Makefile 2017-11-19 12:35:56 +0000 | |||
4 | @@ -463,6 +463,7 @@ | |||
5 | 463 | base=local-launchpad; \ | 463 | base=local-launchpad; \ |
6 | 464 | fi; \ | 464 | fi; \ |
7 | 465 | sed -e 's,%BRANCH_REWRITE%,$(shell pwd)/scripts/branch-rewrite.py,' \ | 465 | sed -e 's,%BRANCH_REWRITE%,$(shell pwd)/scripts/branch-rewrite.py,' \ |
8 | 466 | -e 's,%WSGI_ARCHIVE_AUTH%,$(shell pwd)/scripts/wsgi-archive-auth.py,' \ | ||
9 | 466 | -e 's,%LISTEN_ADDRESS%,$(LISTEN_ADDRESS),' \ | 467 | -e 's,%LISTEN_ADDRESS%,$(LISTEN_ADDRESS),' \ |
10 | 467 | configs/development/local-launchpad-apache > \ | 468 | configs/development/local-launchpad-apache > \ |
11 | 468 | /etc/apache2/sites-available/$$base | 469 | /etc/apache2/sites-available/$$base |
12 | 469 | 470 | ||
13 | === modified file 'configs/development/local-launchpad-apache' | |||
14 | --- configs/development/local-launchpad-apache 2017-01-10 17:26:29 +0000 | |||
15 | +++ configs/development/local-launchpad-apache 2017-11-19 12:35:56 +0000 | |||
16 | @@ -134,7 +134,6 @@ | |||
17 | 134 | 134 | ||
18 | 135 | <VirtualHost %LISTEN_ADDRESS%:80> | 135 | <VirtualHost %LISTEN_ADDRESS%:80> |
19 | 136 | ServerName ppa.launchpad.dev | 136 | ServerName ppa.launchpad.dev |
20 | 137 | ServerAlias private-ppa.launchpad.dev | ||
21 | 138 | LogLevel debug | 137 | LogLevel debug |
22 | 139 | 138 | ||
23 | 140 | DocumentRoot /var/tmp/ppa | 139 | DocumentRoot /var/tmp/ppa |
24 | @@ -147,8 +146,36 @@ | |||
25 | 147 | Deny from all | 146 | Deny from all |
26 | 148 | Allow from 127.0.0.0/255.0.0.0 | 147 | Allow from 127.0.0.0/255.0.0.0 |
27 | 149 | </IfVersion> | 148 | </IfVersion> |
30 | 150 | AllowOverride AuthConfig | 149 | AllowOverride None |
31 | 151 | Options Indexes | 150 | Options Indexes |
32 | 151 | </Directory> | ||
33 | 152 | </VirtualHost> | ||
34 | 153 | |||
35 | 154 | <VirtualHost %LISTEN_ADDRESS%:80> | ||
36 | 155 | ServerName private-ppa.launchpad.dev | ||
37 | 156 | LogLevel debug | ||
38 | 157 | |||
39 | 158 | DocumentRoot /var/tmp/ppa | ||
40 | 159 | <Directory /var/tmp/ppa/> | ||
41 | 160 | <IfVersion >= 2.4> | ||
42 | 161 | <RequireAll> | ||
43 | 162 | Require ip 127.0.0.0/255.0.0.0 | ||
44 | 163 | Require valid-user | ||
45 | 164 | </RequireAll> | ||
46 | 165 | </IfVersion> | ||
47 | 166 | <IfVersion < 2.4> | ||
48 | 167 | Order Deny,Allow | ||
49 | 168 | Deny from all | ||
50 | 169 | Allow from 127.0.0.0/255.0.0.0 | ||
51 | 170 | Require valid-user | ||
52 | 171 | Satisfy All | ||
53 | 172 | </IfVersion> | ||
54 | 173 | AllowOverride None | ||
55 | 174 | Options Indexes | ||
56 | 175 | AuthType Basic | ||
57 | 176 | AuthName "Token Required" | ||
58 | 177 | AuthBasicProvider wsgi | ||
59 | 178 | WSGIAuthUserScript %WSGI_ARCHIVE_AUTH% application-group=lp | ||
60 | 152 | </Directory> | 179 | </Directory> |
61 | 153 | </VirtualHost> | 180 | </VirtualHost> |
62 | 154 | 181 | ||
63 | 155 | 182 | ||
64 | === modified file 'lib/lp/services/config/schema-lazr.conf' | |||
65 | --- lib/lp/services/config/schema-lazr.conf 2017-09-07 13:25:13 +0000 | |||
66 | +++ lib/lp/services/config/schema-lazr.conf 2017-11-19 12:35:56 +0000 | |||
67 | @@ -1460,6 +1460,11 @@ | |||
68 | 1460 | # datatype: boolean | 1460 | # datatype: boolean |
69 | 1461 | require_signing_keys: false | 1461 | require_signing_keys: false |
70 | 1462 | 1462 | ||
71 | 1463 | # The URL to the internal archive API endpoint. This should implement | ||
72 | 1464 | # IArchiveAPI. | ||
73 | 1465 | # datatype: string | ||
74 | 1466 | archive_api_endpoint: http://xmlrpc-private.launchpad.dev:8087/archive | ||
75 | 1467 | |||
76 | 1463 | 1468 | ||
77 | 1464 | [ppa_apache_log_parser] | 1469 | [ppa_apache_log_parser] |
78 | 1465 | logs_root: /srv/ppa.launchpad.net-logs | 1470 | logs_root: /srv/ppa.launchpad.net-logs |
79 | 1466 | 1471 | ||
80 | === modified file 'lib/lp/services/memcache/client.py' | |||
81 | --- lib/lp/services/memcache/client.py 2017-09-27 10:59:13 +0000 | |||
82 | +++ lib/lp/services/memcache/client.py 2017-11-19 12:35:56 +0000 | |||
83 | @@ -4,64 +4,26 @@ | |||
84 | 4 | """Launchpad Memcache client.""" | 4 | """Launchpad Memcache client.""" |
85 | 5 | 5 | ||
86 | 6 | __metaclass__ = type | 6 | __metaclass__ = type |
88 | 7 | __all__ = [] | 7 | __all__ = [ |
89 | 8 | 'memcache_client_factory', | ||
90 | 9 | ] | ||
91 | 8 | 10 | ||
92 | 9 | import logging | ||
93 | 10 | import re | 11 | import re |
94 | 11 | 12 | ||
95 | 12 | from lazr.restful.utils import get_current_browser_request | ||
96 | 13 | import memcache | ||
97 | 14 | |||
98 | 15 | from lp.services import features | ||
99 | 16 | from lp.services.config import config | 13 | from lp.services.config import config |
104 | 17 | from lp.services.timeline.requesttimeline import get_request_timeline | 14 | |
105 | 18 | 15 | ||
106 | 19 | 16 | def memcache_client_factory(timeline=True): | |
103 | 20 | def memcache_client_factory(): | ||
107 | 21 | """Return a memcache.Client for Launchpad.""" | 17 | """Return a memcache.Client for Launchpad.""" |
108 | 22 | servers = [ | 18 | servers = [ |
109 | 23 | (host, int(weight)) for host, weight in re.findall( | 19 | (host, int(weight)) for host, weight in re.findall( |
110 | 24 | r'\((.+?),(\d+)\)', config.memcache.servers)] | 20 | r'\((.+?),(\d+)\)', config.memcache.servers)] |
111 | 25 | assert len(servers) > 0, "Invalid memcached server list %r" % ( | 21 | assert len(servers) > 0, "Invalid memcached server list %r" % ( |
112 | 26 | config.memcache.servers,) | 22 | config.memcache.servers,) |
154 | 27 | return TimelineRecordingClient(servers) | 23 | if timeline: |
155 | 28 | 24 | from lp.services.memcache.timeline import TimelineRecordingClient | |
156 | 29 | 25 | client_factory = TimelineRecordingClient | |
157 | 30 | class TimelineRecordingClient(memcache.Client): | 26 | else: |
158 | 31 | 27 | import memcache | |
159 | 32 | def __get_timeline_action(self, suffix, key): | 28 | client_factory = memcache.Client |
160 | 33 | request = get_current_browser_request() | 29 | return client_factory(servers) |
120 | 34 | timeline = get_request_timeline(request) | ||
121 | 35 | return timeline.start("memcache-%s" % suffix, key) | ||
122 | 36 | |||
123 | 37 | @property | ||
124 | 38 | def _enabled(self): | ||
125 | 39 | configured_value = features.getFeatureFlag('memcache') | ||
126 | 40 | if configured_value is None: | ||
127 | 41 | return True | ||
128 | 42 | else: | ||
129 | 43 | return configured_value | ||
130 | 44 | |||
131 | 45 | def get(self, key): | ||
132 | 46 | if not self._enabled: | ||
133 | 47 | return None | ||
134 | 48 | action = self.__get_timeline_action("get", key) | ||
135 | 49 | try: | ||
136 | 50 | return memcache.Client.get(self, key) | ||
137 | 51 | finally: | ||
138 | 52 | action.finish() | ||
139 | 53 | |||
140 | 54 | def set(self, key, value, time=0, min_compress_len=0): | ||
141 | 55 | if not self._enabled: | ||
142 | 56 | return None | ||
143 | 57 | action = self.__get_timeline_action("set", key) | ||
144 | 58 | try: | ||
145 | 59 | success = memcache.Client.set(self, key, value, time=time, | ||
146 | 60 | min_compress_len=min_compress_len) | ||
147 | 61 | if success: | ||
148 | 62 | logging.debug("Memcache set succeeded for %s", key) | ||
149 | 63 | else: | ||
150 | 64 | logging.warn("Memcache set failed for %s", key) | ||
151 | 65 | return success | ||
152 | 66 | finally: | ||
153 | 67 | action.finish() | ||
161 | 68 | 30 | ||
162 | === modified file 'lib/lp/services/memcache/testing.py' | |||
163 | --- lib/lp/services/memcache/testing.py 2016-09-07 03:43:36 +0000 | |||
164 | +++ lib/lp/services/memcache/testing.py 2017-11-19 12:35:56 +0000 | |||
165 | @@ -1,4 +1,4 @@ | |||
167 | 1 | # Copyright 2016 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2016-2017 Canonical Ltd. This software is licensed under the |
168 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
169 | 3 | 3 | ||
170 | 4 | __metaclass__ = type | 4 | __metaclass__ = type |
171 | @@ -6,6 +6,8 @@ | |||
172 | 6 | 'MemcacheFixture', | 6 | 'MemcacheFixture', |
173 | 7 | ] | 7 | ] |
174 | 8 | 8 | ||
175 | 9 | import time as _time | ||
176 | 10 | |||
177 | 9 | import fixtures | 11 | import fixtures |
178 | 10 | 12 | ||
179 | 11 | from lp.services.memcache.interfaces import IMemcacheClient | 13 | from lp.services.memcache.interfaces import IMemcacheClient |
180 | @@ -22,14 +24,33 @@ | |||
181 | 22 | super(MemcacheFixture, self).setUp() | 24 | super(MemcacheFixture, self).setUp() |
182 | 23 | self.useFixture(ZopeUtilityFixture(self, IMemcacheClient)) | 25 | self.useFixture(ZopeUtilityFixture(self, IMemcacheClient)) |
183 | 24 | 26 | ||
184 | 27 | def check_key(self, key): | ||
185 | 28 | # A subset of the checks performed by the real memcache library; | ||
186 | 29 | # this one is particularly easy to get wrong in Launchpad code. | ||
187 | 30 | if not isinstance(key, str): | ||
188 | 31 | raise TypeError("Key must be str.") | ||
189 | 32 | |||
190 | 25 | def get(self, key): | 33 | def get(self, key): |
192 | 26 | return self._cache.get(key) | 34 | self.check_key(key) |
193 | 35 | value, expiry_time = self._cache.get(key, (None, None)) | ||
194 | 36 | if expiry_time and _time.time() >= expiry_time: | ||
195 | 37 | self.delete(key) | ||
196 | 38 | return None | ||
197 | 39 | else: | ||
198 | 40 | return value | ||
199 | 27 | 41 | ||
202 | 28 | def set(self, key, val): | 42 | def set(self, key, val, time=0): |
203 | 29 | self._cache[key] = val | 43 | self.check_key(key) |
204 | 44 | # memcached accepts either delta-seconds from the current time or | ||
205 | 45 | # absolute epoch-seconds, and tells them apart using a magic | ||
206 | 46 | # threshold. See memcached/memcached.c:realtime. | ||
207 | 47 | if time and time <= 60 * 60 * 24 * 30: | ||
208 | 48 | time = _time.time() + time | ||
209 | 49 | self._cache[key] = (val, time) | ||
210 | 30 | return 1 | 50 | return 1 |
211 | 31 | 51 | ||
212 | 32 | def delete(self, key): | 52 | def delete(self, key): |
213 | 53 | self.check_key(key) | ||
214 | 33 | self._cache.pop(key, None) | 54 | self._cache.pop(key, None) |
215 | 34 | return 1 | 55 | return 1 |
216 | 35 | 56 | ||
217 | 36 | 57 | ||
218 | === added file 'lib/lp/services/memcache/timeline.py' | |||
219 | --- lib/lp/services/memcache/timeline.py 1970-01-01 00:00:00 +0000 | |||
220 | +++ lib/lp/services/memcache/timeline.py 2017-11-19 12:35:56 +0000 | |||
221 | @@ -0,0 +1,57 @@ | |||
222 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
223 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
224 | 3 | |||
225 | 4 | """Timeline-friendly Launchpad Memcache client.""" | ||
226 | 5 | |||
227 | 6 | __metaclass__ = type | ||
228 | 7 | __all__ = [ | ||
229 | 8 | 'TimelineRecordingClient', | ||
230 | 9 | ] | ||
231 | 10 | |||
232 | 11 | import logging | ||
233 | 12 | |||
234 | 13 | from lazr.restful.utils import get_current_browser_request | ||
235 | 14 | import memcache | ||
236 | 15 | |||
237 | 16 | from lp.services import features | ||
238 | 17 | from lp.services.timeline.requesttimeline import get_request_timeline | ||
239 | 18 | |||
240 | 19 | |||
241 | 20 | class TimelineRecordingClient(memcache.Client): | ||
242 | 21 | |||
243 | 22 | def __get_timeline_action(self, suffix, key): | ||
244 | 23 | request = get_current_browser_request() | ||
245 | 24 | timeline = get_request_timeline(request) | ||
246 | 25 | return timeline.start("memcache-%s" % suffix, key) | ||
247 | 26 | |||
248 | 27 | @property | ||
249 | 28 | def _enabled(self): | ||
250 | 29 | configured_value = features.getFeatureFlag('memcache') | ||
251 | 30 | if configured_value is None: | ||
252 | 31 | return True | ||
253 | 32 | else: | ||
254 | 33 | return configured_value | ||
255 | 34 | |||
256 | 35 | def get(self, key): | ||
257 | 36 | if not self._enabled: | ||
258 | 37 | return None | ||
259 | 38 | action = self.__get_timeline_action("get", key) | ||
260 | 39 | try: | ||
261 | 40 | return memcache.Client.get(self, key) | ||
262 | 41 | finally: | ||
263 | 42 | action.finish() | ||
264 | 43 | |||
265 | 44 | def set(self, key, value, time=0, min_compress_len=0): | ||
266 | 45 | if not self._enabled: | ||
267 | 46 | return None | ||
268 | 47 | action = self.__get_timeline_action("set", key) | ||
269 | 48 | try: | ||
270 | 49 | success = memcache.Client.set(self, key, value, time=time, | ||
271 | 50 | min_compress_len=min_compress_len) | ||
272 | 51 | if success: | ||
273 | 52 | logging.debug("Memcache set succeeded for %s", key) | ||
274 | 53 | else: | ||
275 | 54 | logging.warn("Memcache set failed for %s", key) | ||
276 | 55 | return success | ||
277 | 56 | finally: | ||
278 | 57 | action.finish() | ||
279 | 0 | 58 | ||
280 | === modified file 'lib/lp/soyuz/doc/archiveauthtoken.txt' | |||
281 | --- lib/lp/soyuz/doc/archiveauthtoken.txt 2012-04-10 14:01:17 +0000 | |||
282 | +++ lib/lp/soyuz/doc/archiveauthtoken.txt 2017-11-19 12:35:56 +0000 | |||
283 | @@ -139,12 +139,35 @@ | |||
284 | 139 | ... print token.person.name | 139 | ... print token.person.name |
285 | 140 | bradsmith | 140 | bradsmith |
286 | 141 | 141 | ||
288 | 142 | Tokens can also be retreived by archive and person: | 142 | Tokens can also be retrieved by archive and person: |
289 | 143 | 143 | ||
290 | 144 | >>> print token_set.getActiveTokenForArchiveAndPerson( | 144 | >>> print token_set.getActiveTokenForArchiveAndPerson( |
291 | 145 | ... new_token.archive, new_token.person).token | 145 | ... new_token.archive, new_token.person).token |
292 | 146 | testtoken | 146 | testtoken |
293 | 147 | 147 | ||
294 | 148 | Or by archive and person name: | ||
295 | 149 | |||
296 | 150 | >>> print token_set.getActiveTokenForArchiveAndPersonName( | ||
297 | 151 | ... new_token.archive, "bradsmith").token | ||
298 | 152 | testtoken | ||
299 | 153 | |||
300 | 154 | Tokens are only returned if they match a current subscription: | ||
301 | 155 | |||
302 | 156 | >>> from zope.security.proxy import removeSecurityProxy | ||
303 | 157 | >>> from lp.soyuz.enums import ArchiveSubscriberStatus | ||
304 | 158 | >>> removeSecurityProxy(subscription_to_joe_private_ppa).status = ( | ||
305 | 159 | ... ArchiveSubscriberStatus.EXPIRED) | ||
306 | 160 | |||
307 | 161 | >>> print token_set.getActiveTokenForArchiveAndPerson( | ||
308 | 162 | ... new_token.archive, new_token.person) | ||
309 | 163 | None | ||
310 | 164 | >>> print token_set.getActiveTokenForArchiveAndPersonName( | ||
311 | 165 | ... new_token.archive, "bradsmith") | ||
312 | 166 | None | ||
313 | 167 | |||
314 | 168 | >>> removeSecurityProxy(subscription_to_joe_private_ppa).status = ( | ||
315 | 169 | ... ArchiveSubscriberStatus.CURRENT) | ||
316 | 170 | |||
317 | 148 | 171 | ||
318 | 149 | == Amending Tokens == | 172 | == Amending Tokens == |
319 | 150 | 173 | ||
320 | 151 | 174 | ||
321 | === added file 'lib/lp/soyuz/interfaces/archiveapi.py' | |||
322 | --- lib/lp/soyuz/interfaces/archiveapi.py 1970-01-01 00:00:00 +0000 | |||
323 | +++ lib/lp/soyuz/interfaces/archiveapi.py 2017-11-19 12:35:56 +0000 | |||
324 | @@ -0,0 +1,46 @@ | |||
325 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
326 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
327 | 3 | |||
328 | 4 | """Interfaces for internal archive APIs.""" | ||
329 | 5 | |||
330 | 6 | from __future__ import absolute_import, print_function, unicode_literals | ||
331 | 7 | |||
332 | 8 | __metaclass__ = type | ||
333 | 9 | __all__ = [ | ||
334 | 10 | 'IArchiveAPI', | ||
335 | 11 | 'IArchiveApplication', | ||
336 | 12 | ] | ||
337 | 13 | |||
338 | 14 | from zope.interface import Interface | ||
339 | 15 | |||
340 | 16 | from lp.services.webapp.interfaces import ILaunchpadApplication | ||
341 | 17 | |||
342 | 18 | |||
343 | 19 | class IArchiveApplication(ILaunchpadApplication): | ||
344 | 20 | """Archive application root.""" | ||
345 | 21 | |||
346 | 22 | |||
347 | 23 | class IArchiveAPI(Interface): | ||
348 | 24 | """The Soyuz archive XML-RPC interface to Launchpad. | ||
349 | 25 | |||
350 | 26 | Published at "archive" on the private XML-RPC server. | ||
351 | 27 | |||
352 | 28 | PPA frontends use this to check archive authorization tokens. | ||
353 | 29 | """ | ||
354 | 30 | |||
355 | 31 | def checkArchiveAuthToken(archive_reference, username, password): | ||
356 | 32 | """Check an archive authorization token. | ||
357 | 33 | |||
358 | 34 | :param archive_reference: The reference form of the archive to check. | ||
359 | 35 | :param username: The username sent using HTTP Basic Authentication; | ||
360 | 36 | this should either be a `Person.name` or "+" followed by the | ||
361 | 37 | name of a named authorization token. | ||
362 | 38 | :param password: The password sent using HTTP Basic Authentication; | ||
363 | 39 | this should be a corresponding `ArchiveAuthToken.token`. | ||
364 | 40 | |||
365 | 41 | :returns: A `NotFound` fault if `archive_reference` does not | ||
366 | 42 | identify an archive or the username does not identify a valid | ||
367 | 43 | token for this archive; an `Unauthorized` fault if the password | ||
368 | 44 | is not equal to the selected token for this archive; otherwise | ||
369 | 45 | None. | ||
370 | 46 | """ | ||
371 | 0 | 47 | ||
372 | === modified file 'lib/lp/soyuz/interfaces/archiveauthtoken.py' | |||
373 | --- lib/lp/soyuz/interfaces/archiveauthtoken.py 2016-07-14 16:06:01 +0000 | |||
374 | +++ lib/lp/soyuz/interfaces/archiveauthtoken.py 2017-11-19 12:35:56 +0000 | |||
375 | @@ -96,23 +96,33 @@ | |||
376 | 96 | :return: An object conforming to `IArchiveAuthToken`. | 96 | :return: An object conforming to `IArchiveAuthToken`. |
377 | 97 | """ | 97 | """ |
378 | 98 | 98 | ||
380 | 99 | def getByArchive(archive): | 99 | def getByArchive(archive, valid=False): |
381 | 100 | """Retrieve all the tokens for an archive. | 100 | """Retrieve all the tokens for an archive. |
382 | 101 | 101 | ||
383 | 102 | :param archive: The context archive. | 102 | :param archive: The context archive. |
384 | 103 | :param valid: If True, only return valid tokens. | ||
385 | 103 | :return: A result set containing `IArchiveAuthToken`s. | 104 | :return: A result set containing `IArchiveAuthToken`s. |
386 | 104 | """ | 105 | """ |
387 | 105 | 106 | ||
388 | 106 | def getActiveTokenForArchiveAndPerson(archive, person): | 107 | def getActiveTokenForArchiveAndPerson(archive, person): |
390 | 107 | """Retrieve an active token for the given archive and person. | 108 | """Retrieve a valid active token for the given archive and person. |
391 | 108 | 109 | ||
392 | 109 | :param archive: The archive to which the token corresponds. | 110 | :param archive: The archive to which the token corresponds. |
393 | 110 | :param person: The person to which the token corresponds. | 111 | :param person: The person to which the token corresponds. |
394 | 111 | :return: An `IArchiveAuthToken` or None. | 112 | :return: An `IArchiveAuthToken` or None. |
395 | 112 | """ | 113 | """ |
396 | 113 | 114 | ||
397 | 115 | def getActiveTokenForArchiveAndPersonName(archive, person_name): | ||
398 | 116 | """Retrieve a valid active token for the given archive and person name. | ||
399 | 117 | |||
400 | 118 | :param archive: The archive to which the token corresponds. | ||
401 | 119 | :param person_name: The name of the person to which the token | ||
402 | 120 | corresponds. | ||
403 | 121 | :return: An `IArchiveAuthToken` or None. | ||
404 | 122 | """ | ||
405 | 123 | |||
406 | 114 | def getActiveNamedTokenForArchive(archive, name): | 124 | def getActiveNamedTokenForArchive(archive, name): |
408 | 115 | """Retrieve an active named token for the given archive and name. | 125 | """Retrieve a valid active named token for the given archive and name. |
409 | 116 | 126 | ||
410 | 117 | :param archive: The archive to which the token corresponds. | 127 | :param archive: The archive to which the token corresponds. |
411 | 118 | :param name: The name of a named authorization token. | 128 | :param name: The name of a named authorization token. |
412 | @@ -120,9 +130,9 @@ | |||
413 | 120 | """ | 130 | """ |
414 | 121 | 131 | ||
415 | 122 | def getActiveNamedTokensForArchive(archive, names=None): | 132 | def getActiveNamedTokensForArchive(archive, names=None): |
419 | 123 | """Retrieve a subset of active named tokens for the given archive if | 133 | """Retrieve a subset of valid active named tokens for the given |
420 | 124 | `names` is specified, or all active named tokens for the archive if | 134 | archive if `names` is specified, or all valid active named tokens |
421 | 125 | `names` is null. | 135 | for the archive if `names` is null. |
422 | 126 | 136 | ||
423 | 127 | :param archive: The archive to which the tokens correspond. | 137 | :param archive: The archive to which the tokens correspond. |
424 | 128 | :param names: An optional list of token names. | 138 | :param names: An optional list of token names. |
425 | 129 | 139 | ||
426 | === modified file 'lib/lp/soyuz/model/archiveauthtoken.py' | |||
427 | --- lib/lp/soyuz/model/archiveauthtoken.py 2016-07-14 16:06:01 +0000 | |||
428 | +++ lib/lp/soyuz/model/archiveauthtoken.py 2017-11-19 12:35:56 +0000 | |||
429 | @@ -21,8 +21,10 @@ | |||
430 | 21 | from storm.store import Store | 21 | from storm.store import Store |
431 | 22 | from zope.interface import implementer | 22 | from zope.interface import implementer |
432 | 23 | 23 | ||
433 | 24 | from lp.registry.model.teammembership import TeamParticipation | ||
434 | 24 | from lp.services.database.constants import UTC_NOW | 25 | from lp.services.database.constants import UTC_NOW |
435 | 25 | from lp.services.database.interfaces import IStore | 26 | from lp.services.database.interfaces import IStore |
436 | 27 | from lp.soyuz.enums import ArchiveSubscriberStatus | ||
437 | 26 | from lp.soyuz.interfaces.archiveauthtoken import ( | 28 | from lp.soyuz.interfaces.archiveauthtoken import ( |
438 | 27 | IArchiveAuthToken, | 29 | IArchiveAuthToken, |
439 | 28 | IArchiveAuthTokenSet, | 30 | IArchiveAuthTokenSet, |
440 | @@ -85,19 +87,37 @@ | |||
441 | 85 | return IStore(ArchiveAuthToken).find( | 87 | return IStore(ArchiveAuthToken).find( |
442 | 86 | ArchiveAuthToken, ArchiveAuthToken.token == token).one() | 88 | ArchiveAuthToken, ArchiveAuthToken.token == token).one() |
443 | 87 | 89 | ||
445 | 88 | def getByArchive(self, archive): | 90 | def getByArchive(self, archive, valid=False): |
446 | 89 | """See `IArchiveAuthTokenSet`.""" | 91 | """See `IArchiveAuthTokenSet`.""" |
447 | 92 | # Circular import. | ||
448 | 93 | from lp.soyuz.model.archivesubscriber import ArchiveSubscriber | ||
449 | 90 | store = Store.of(archive) | 94 | store = Store.of(archive) |
452 | 91 | return store.find( | 95 | clauses = [ |
451 | 92 | ArchiveAuthToken, | ||
453 | 93 | ArchiveAuthToken.archive == archive, | 96 | ArchiveAuthToken.archive == archive, |
455 | 94 | ArchiveAuthToken.date_deactivated == None) | 97 | ArchiveAuthToken.date_deactivated == None, |
456 | 98 | ] | ||
457 | 99 | if valid: | ||
458 | 100 | clauses.extend([ | ||
459 | 101 | ArchiveAuthToken.archive_id == ArchiveSubscriber.archive_id, | ||
460 | 102 | ArchiveSubscriber.status == ArchiveSubscriberStatus.CURRENT, | ||
461 | 103 | ArchiveSubscriber.subscriber_id == TeamParticipation.teamID, | ||
462 | 104 | TeamParticipation.personID == ArchiveAuthToken.person_id, | ||
463 | 105 | ]) | ||
464 | 106 | return store.find(ArchiveAuthToken, *clauses) | ||
465 | 95 | 107 | ||
466 | 96 | def getActiveTokenForArchiveAndPerson(self, archive, person): | 108 | def getActiveTokenForArchiveAndPerson(self, archive, person): |
467 | 97 | """See `IArchiveAuthTokenSet`.""" | 109 | """See `IArchiveAuthTokenSet`.""" |
469 | 98 | return self.getByArchive(archive).find( | 110 | return self.getByArchive(archive, valid=True).find( |
470 | 99 | ArchiveAuthToken.person == person).one() | 111 | ArchiveAuthToken.person == person).one() |
471 | 100 | 112 | ||
472 | 113 | def getActiveTokenForArchiveAndPersonName(self, archive, person_name): | ||
473 | 114 | """See `IArchiveAuthTokenSet`.""" | ||
474 | 115 | # Circular import. | ||
475 | 116 | from lp.registry.model.person import Person | ||
476 | 117 | return self.getByArchive(archive, valid=True).find( | ||
477 | 118 | ArchiveAuthToken.person == Person.id, | ||
478 | 119 | Person.name == person_name).one() | ||
479 | 120 | |||
480 | 101 | def getActiveNamedTokenForArchive(self, archive, name): | 121 | def getActiveNamedTokenForArchive(self, archive, name): |
481 | 102 | """See `IArchiveAuthTokenSet`.""" | 122 | """See `IArchiveAuthTokenSet`.""" |
482 | 103 | return self.getByArchive(archive).find( | 123 | return self.getByArchive(archive).find( |
483 | 104 | 124 | ||
484 | === added directory 'lib/lp/soyuz/wsgi' | |||
485 | === added file 'lib/lp/soyuz/wsgi/__init__.py' | |||
486 | === added file 'lib/lp/soyuz/wsgi/archiveauth.py' | |||
487 | --- lib/lp/soyuz/wsgi/archiveauth.py 1970-01-01 00:00:00 +0000 | |||
488 | +++ lib/lp/soyuz/wsgi/archiveauth.py 2017-11-19 12:35:56 +0000 | |||
489 | @@ -0,0 +1,83 @@ | |||
490 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
491 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
492 | 3 | |||
493 | 4 | """WSGI archive authorisation provider. | ||
494 | 5 | |||
495 | 6 | This is as lightweight as possible, as it runs on PPA frontends. | ||
496 | 7 | """ | ||
497 | 8 | |||
498 | 9 | from __future__ import absolute_import, print_function, unicode_literals | ||
499 | 10 | |||
500 | 11 | __metaclass__ = type | ||
501 | 12 | __all__ = [ | ||
502 | 13 | 'check_password', | ||
503 | 14 | ] | ||
504 | 15 | |||
505 | 16 | import crypt | ||
506 | 17 | from random import SystemRandom | ||
507 | 18 | import string | ||
508 | 19 | import time | ||
509 | 20 | try: | ||
510 | 21 | from xmlrpc.client import ( | ||
511 | 22 | Fault, | ||
512 | 23 | ServerProxy, | ||
513 | 24 | ) | ||
514 | 25 | except ImportError: | ||
515 | 26 | from xmlrpclib import ( | ||
516 | 27 | Fault, | ||
517 | 28 | ServerProxy, | ||
518 | 29 | ) | ||
519 | 30 | |||
520 | 31 | from lp.services.config import config | ||
521 | 32 | from lp.services.memcache.client import memcache_client_factory | ||
522 | 33 | |||
523 | 34 | |||
524 | 35 | def _get_archive_reference(environ): | ||
525 | 36 | # Reconstruct the relevant part of the URL. We don't care about where | ||
526 | 37 | # we're installed. | ||
527 | 38 | path = environ.get("SCRIPT_NAME") or "/" | ||
528 | 39 | path_info = environ.get("PATH_INFO", "") | ||
529 | 40 | path += (path_info if path else path_info[1:]) | ||
530 | 41 | # Extract the first three segments of the path, and rearrange them to | ||
531 | 42 | # form an archive reference. | ||
532 | 43 | path_parts = path.lstrip("/").split("/") | ||
533 | 44 | if len(path_parts) >= 3: | ||
534 | 45 | return "~%s/%s/%s" % (path_parts[0], path_parts[2], path_parts[1]) | ||
535 | 46 | |||
536 | 47 | |||
537 | 48 | _sr = SystemRandom() | ||
538 | 49 | |||
539 | 50 | |||
540 | 51 | def _crypt_sha256(word): | ||
541 | 52 | """crypt.crypt(word, crypt.METHOD_SHA256), backported from Python 3.5.""" | ||
542 | 53 | saltchars = string.ascii_letters + string.digits + './' | ||
543 | 54 | salt = '$5$' + ''.join(_sr.choice(saltchars) for _ in range(16)) | ||
544 | 55 | return crypt.crypt(word, salt) | ||
545 | 56 | |||
546 | 57 | |||
547 | 58 | _memcache_client = memcache_client_factory(timeline=False) | ||
548 | 59 | |||
549 | 60 | |||
550 | 61 | def check_password(environ, user, password): | ||
551 | 62 | archive_reference = _get_archive_reference(environ) | ||
552 | 63 | if archive_reference is None: | ||
553 | 64 | return None | ||
554 | 65 | memcache_key = ( | ||
555 | 66 | "archive-auth:%s:%s" % (archive_reference, user)).encode("UTF-8") | ||
556 | 67 | crypted_password = _memcache_client.get(memcache_key) | ||
557 | 68 | if (crypted_password and | ||
558 | 69 | crypt.crypt(password, crypted_password) == crypted_password): | ||
559 | 70 | return True | ||
560 | 71 | proxy = ServerProxy(config.personalpackagearchive.archive_api_endpoint) | ||
561 | 72 | try: | ||
562 | 73 | proxy.checkArchiveAuthToken(archive_reference, user, password) | ||
563 | 74 | # Cache positive responses for a minute to reduce database load. | ||
564 | 75 | _memcache_client.set( | ||
565 | 76 | memcache_key, _crypt_sha256(password), time.time() + 60) | ||
566 | 77 | return True | ||
567 | 78 | except Fault as e: | ||
568 | 79 | if e.faultCode == 410: # Unauthorized | ||
569 | 80 | return False | ||
570 | 81 | else: | ||
571 | 82 | # Interpret any other fault as NotFound (320). | ||
572 | 83 | return None | ||
573 | 0 | 84 | ||
574 | === added directory 'lib/lp/soyuz/wsgi/tests' | |||
575 | === added file 'lib/lp/soyuz/wsgi/tests/__init__.py' | |||
576 | === added file 'lib/lp/soyuz/wsgi/tests/test_archiveauth.py' | |||
577 | --- lib/lp/soyuz/wsgi/tests/test_archiveauth.py 1970-01-01 00:00:00 +0000 | |||
578 | +++ lib/lp/soyuz/wsgi/tests/test_archiveauth.py 2017-11-19 12:35:56 +0000 | |||
579 | @@ -0,0 +1,151 @@ | |||
580 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
581 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
582 | 3 | |||
583 | 4 | """Tests for the WSGI archive authorisation provider.""" | ||
584 | 5 | |||
585 | 6 | from __future__ import absolute_import, print_function, unicode_literals | ||
586 | 7 | |||
587 | 8 | __metaclass__ = type | ||
588 | 9 | |||
589 | 10 | import crypt | ||
590 | 11 | import os.path | ||
591 | 12 | import subprocess | ||
592 | 13 | import time | ||
593 | 14 | |||
594 | 15 | from fixtures import MonkeyPatch | ||
595 | 16 | from testtools.matchers import Is | ||
596 | 17 | import transaction | ||
597 | 18 | |||
598 | 19 | from lp.services.config import config | ||
599 | 20 | from lp.services.memcache.testing import MemcacheFixture | ||
600 | 21 | from lp.soyuz.wsgi import archiveauth | ||
601 | 22 | from lp.testing import TestCaseWithFactory | ||
602 | 23 | from lp.testing.layers import ZopelessAppServerLayer | ||
603 | 24 | from lp.xmlrpc import faults | ||
604 | 25 | |||
605 | 26 | |||
606 | 27 | class TestWSGIArchiveAuth(TestCaseWithFactory): | ||
607 | 28 | |||
608 | 29 | layer = ZopelessAppServerLayer | ||
609 | 30 | |||
610 | 31 | def setUp(self): | ||
611 | 32 | super(TestWSGIArchiveAuth, self).setUp() | ||
612 | 33 | self.now = time.time() | ||
613 | 34 | self.useFixture(MonkeyPatch("time.time", lambda: self.now)) | ||
614 | 35 | self.memcache_fixture = self.useFixture(MemcacheFixture()) | ||
615 | 36 | # The WSGI provider doesn't use Zope, so we can't rely on the | ||
616 | 37 | # fixture substituting a Zope utility. | ||
617 | 38 | self.useFixture(MonkeyPatch( | ||
618 | 39 | "lp.soyuz.wsgi.archiveauth._memcache_client", | ||
619 | 40 | self.memcache_fixture)) | ||
620 | 41 | |||
621 | 42 | def test_get_archive_reference_short_url(self): | ||
622 | 43 | self.assertIsNone(archiveauth._get_archive_reference( | ||
623 | 44 | {"SCRIPT_NAME": "/foo"})) | ||
624 | 45 | |||
625 | 46 | def test_get_archive_reference_archive_base(self): | ||
626 | 47 | self.assertEqual( | ||
627 | 48 | "~user/ubuntu/ppa", | ||
628 | 49 | archiveauth._get_archive_reference( | ||
629 | 50 | {"SCRIPT_NAME": "/user/ppa/ubuntu"})) | ||
630 | 51 | |||
631 | 52 | def test_get_archive_reference_inside_archive(self): | ||
632 | 53 | self.assertEqual( | ||
633 | 54 | "~user/ubuntu/ppa", | ||
634 | 55 | archiveauth._get_archive_reference( | ||
635 | 56 | {"SCRIPT_NAME": "/user/ppa/ubuntu/dists"})) | ||
636 | 57 | |||
637 | 58 | def test_check_password_short_url(self): | ||
638 | 59 | self.assertIsNone(archiveauth.check_password( | ||
639 | 60 | {"SCRIPT_NAME": "/foo"}, "user", "")) | ||
640 | 61 | self.assertEqual({}, self.memcache_fixture._cache) | ||
641 | 62 | |||
642 | 63 | def test_check_password_not_found(self): | ||
643 | 64 | self.assertIsNone(archiveauth.check_password( | ||
644 | 65 | {"SCRIPT_NAME": "/nonexistent/bad/unknown"}, "user", "")) | ||
645 | 66 | self.assertEqual({}, self.memcache_fixture._cache) | ||
646 | 67 | |||
647 | 68 | def test_crypt_sha256(self): | ||
648 | 69 | crypted_password = archiveauth._crypt_sha256("secret") | ||
649 | 70 | self.assertEqual( | ||
650 | 71 | crypted_password, crypt.crypt("secret", crypted_password)) | ||
651 | 72 | |||
652 | 73 | def makeArchiveAndToken(self): | ||
653 | 74 | archive = self.factory.makeArchive(private=True) | ||
654 | 75 | archive_path = "/%s/%s/ubuntu" % (archive.owner.name, archive.name) | ||
655 | 76 | subscriber = self.factory.makePerson() | ||
656 | 77 | archive.newSubscription(subscriber, archive.owner) | ||
657 | 78 | token = archive.newAuthToken(subscriber) | ||
658 | 79 | transaction.commit() | ||
659 | 80 | return archive, archive_path, subscriber.name, token.token | ||
660 | 81 | |||
661 | 82 | def test_check_password_unauthorized(self): | ||
662 | 83 | _, archive_path, username, password = self.makeArchiveAndToken() | ||
663 | 84 | # Test that this returns False, not merely something falsy (e.g. | ||
664 | 85 | # None). | ||
665 | 86 | self.assertThat( | ||
666 | 87 | archiveauth.check_password( | ||
667 | 88 | {"SCRIPT_NAME": archive_path}, username, password + "-bad"), | ||
668 | 89 | Is(False)) | ||
669 | 90 | self.assertEqual({}, self.memcache_fixture._cache) | ||
670 | 91 | |||
671 | 92 | def test_check_password_success(self): | ||
672 | 93 | archive, archive_path, username, password = self.makeArchiveAndToken() | ||
673 | 94 | self.assertThat( | ||
674 | 95 | archiveauth.check_password( | ||
675 | 96 | {"SCRIPT_NAME": archive_path}, username, password), | ||
676 | 97 | Is(True)) | ||
677 | 98 | crypted_password = self.memcache_fixture.get( | ||
678 | 99 | ("archive-auth:%s:%s" % (archive.reference, username)).encode( | ||
679 | 100 | "UTF-8")) | ||
680 | 101 | self.assertEqual( | ||
681 | 102 | crypted_password, crypt.crypt(password, crypted_password)) | ||
682 | 103 | |||
683 | 104 | def test_check_password_considers_cache(self): | ||
684 | 105 | class FakeProxy: | ||
685 | 106 | def __init__(self, uri): | ||
686 | 107 | pass | ||
687 | 108 | |||
688 | 109 | def checkArchiveAuthToken(self, archive_reference, username, | ||
689 | 110 | password): | ||
690 | 111 | raise faults.Unauthorized() | ||
691 | 112 | |||
692 | 113 | _, archive_path, username, password = self.makeArchiveAndToken() | ||
693 | 114 | self.assertThat( | ||
694 | 115 | archiveauth.check_password( | ||
695 | 116 | {"SCRIPT_NAME": archive_path}, username, password), | ||
696 | 117 | Is(True)) | ||
697 | 118 | self.useFixture( | ||
698 | 119 | MonkeyPatch("lp.soyuz.wsgi.archiveauth.ServerProxy", FakeProxy)) | ||
699 | 120 | # A subsequent check honours the cache. | ||
700 | 121 | self.assertThat( | ||
701 | 122 | archiveauth.check_password( | ||
702 | 123 | {"SCRIPT_NAME": archive_path}, username, password + "-bad"), | ||
703 | 124 | Is(False)) | ||
704 | 125 | self.assertThat( | ||
705 | 126 | archiveauth.check_password( | ||
706 | 127 | {"SCRIPT_NAME": archive_path}, username, password), | ||
707 | 128 | Is(True)) | ||
708 | 129 | # If we advance time far enough, then the cached result expires. | ||
709 | 130 | self.now += 60 | ||
710 | 131 | self.assertThat( | ||
711 | 132 | archiveauth.check_password( | ||
712 | 133 | {"SCRIPT_NAME": archive_path}, username, password), | ||
713 | 134 | Is(False)) | ||
714 | 135 | |||
715 | 136 | def test_script(self): | ||
716 | 137 | _, archive_path, username, password = self.makeArchiveAndToken() | ||
717 | 138 | script_path = os.path.join( | ||
718 | 139 | config.root, "scripts", "wsgi-archive-auth.py") | ||
719 | 140 | |||
720 | 141 | def check_via_script(archive_path, username, password): | ||
721 | 142 | with open(os.devnull, "w") as devnull: | ||
722 | 143 | return subprocess.call( | ||
723 | 144 | [script_path, archive_path, username, password], | ||
724 | 145 | stderr=devnull) | ||
725 | 146 | |||
726 | 147 | self.assertEqual(0, check_via_script(archive_path, username, password)) | ||
727 | 148 | self.assertEqual( | ||
728 | 149 | 1, check_via_script(archive_path, username, password + "-bad")) | ||
729 | 150 | self.assertEqual( | ||
730 | 151 | 2, check_via_script("/nonexistent/bad/unknown", "user", "")) | ||
731 | 0 | 152 | ||
732 | === added file 'lib/lp/soyuz/xmlrpc/archive.py' | |||
733 | --- lib/lp/soyuz/xmlrpc/archive.py 1970-01-01 00:00:00 +0000 | |||
734 | +++ lib/lp/soyuz/xmlrpc/archive.py 2017-11-19 12:35:56 +0000 | |||
735 | @@ -0,0 +1,62 @@ | |||
736 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
737 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
738 | 3 | |||
739 | 4 | """Implementations of the XML-RPC APIs for Soyuz archives.""" | ||
740 | 5 | |||
741 | 6 | from __future__ import absolute_import, print_function, unicode_literals | ||
742 | 7 | |||
743 | 8 | __metaclass__ = type | ||
744 | 9 | __all__ = [ | ||
745 | 10 | 'ArchiveAPI', | ||
746 | 11 | ] | ||
747 | 12 | |||
748 | 13 | from zope.component import getUtility | ||
749 | 14 | from zope.interface import implementer | ||
750 | 15 | from zope.security.proxy import removeSecurityProxy | ||
751 | 16 | |||
752 | 17 | from lp.soyuz.interfaces.archive import IArchiveSet | ||
753 | 18 | from lp.soyuz.interfaces.archiveapi import IArchiveAPI | ||
754 | 19 | from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthTokenSet | ||
755 | 20 | from lp.services.webapp import LaunchpadXMLRPCView | ||
756 | 21 | from lp.xmlrpc import faults | ||
757 | 22 | from lp.xmlrpc.helpers import return_fault | ||
758 | 23 | |||
759 | 24 | |||
760 | 25 | BUILDD_USER_NAME = "buildd" | ||
761 | 26 | |||
762 | 27 | |||
763 | 28 | @implementer(IArchiveAPI) | ||
764 | 29 | class ArchiveAPI(LaunchpadXMLRPCView): | ||
765 | 30 | """See `IArchiveAPI`.""" | ||
766 | 31 | |||
767 | 32 | @return_fault | ||
768 | 33 | def _checkArchiveAuthToken(self, archive_reference, username, password): | ||
769 | 34 | archive = getUtility(IArchiveSet).getByReference(archive_reference) | ||
770 | 35 | if archive is None: | ||
771 | 36 | raise faults.NotFound( | ||
772 | 37 | message="No archive found for '%s'." % archive_reference) | ||
773 | 38 | archive = removeSecurityProxy(archive) | ||
774 | 39 | token_set = getUtility(IArchiveAuthTokenSet) | ||
775 | 40 | if username == BUILDD_USER_NAME: | ||
776 | 41 | secret = archive.buildd_secret | ||
777 | 42 | else: | ||
778 | 43 | if username.startswith("+"): | ||
779 | 44 | token = token_set.getActiveNamedTokenForArchive( | ||
780 | 45 | archive, username[1:]) | ||
781 | 46 | else: | ||
782 | 47 | token = token_set.getActiveTokenForArchiveAndPersonName( | ||
783 | 48 | archive, username) | ||
784 | 49 | if token is None: | ||
785 | 50 | raise faults.NotFound( | ||
786 | 51 | message="No valid tokens for '%s' in '%s'." % ( | ||
787 | 52 | username, archive_reference)) | ||
788 | 53 | secret = removeSecurityProxy(token).token | ||
789 | 54 | if password != secret: | ||
790 | 55 | raise faults.Unauthorized() | ||
791 | 56 | |||
792 | 57 | def checkArchiveAuthToken(self, archive_reference, username, password): | ||
793 | 58 | """See `IArchiveAPI`.""" | ||
794 | 59 | # This thunk exists because you can't use a decorated function as | ||
795 | 60 | # the implementation of a method exported over XML-RPC. | ||
796 | 61 | return self._checkArchiveAuthToken( | ||
797 | 62 | archive_reference, username, password) | ||
798 | 0 | 63 | ||
799 | === added directory 'lib/lp/soyuz/xmlrpc/tests' | |||
800 | === added file 'lib/lp/soyuz/xmlrpc/tests/__init__.py' | |||
801 | === added file 'lib/lp/soyuz/xmlrpc/tests/test_archive.py' | |||
802 | --- lib/lp/soyuz/xmlrpc/tests/test_archive.py 1970-01-01 00:00:00 +0000 | |||
803 | +++ lib/lp/soyuz/xmlrpc/tests/test_archive.py 2017-11-19 12:35:56 +0000 | |||
804 | @@ -0,0 +1,124 @@ | |||
805 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
806 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
807 | 3 | |||
808 | 4 | """Tests for the internal Soyuz archive API.""" | ||
809 | 5 | |||
810 | 6 | from __future__ import absolute_import, print_function, unicode_literals | ||
811 | 7 | |||
812 | 8 | __metaclass__ = type | ||
813 | 9 | |||
814 | 10 | from zope.security.proxy import removeSecurityProxy | ||
815 | 11 | |||
816 | 12 | from lp.services.features.testing import FeatureFixture | ||
817 | 13 | from lp.soyuz.interfaces.archive import NAMED_AUTH_TOKEN_FEATURE_FLAG | ||
818 | 14 | from lp.soyuz.xmlrpc.archive import ArchiveAPI | ||
819 | 15 | from lp.testing import TestCaseWithFactory | ||
820 | 16 | from lp.testing.layers import LaunchpadFunctionalLayer | ||
821 | 17 | from lp.xmlrpc import faults | ||
822 | 18 | |||
823 | 19 | |||
824 | 20 | class TestArchiveAPI(TestCaseWithFactory): | ||
825 | 21 | |||
826 | 22 | layer = LaunchpadFunctionalLayer | ||
827 | 23 | |||
828 | 24 | def setUp(self): | ||
829 | 25 | super(TestArchiveAPI, self).setUp() | ||
830 | 26 | self.useFixture(FeatureFixture({NAMED_AUTH_TOKEN_FEATURE_FLAG: "on"})) | ||
831 | 27 | self.archive_api = ArchiveAPI(None, None) | ||
832 | 28 | |||
833 | 29 | def assertNotFound(self, archive_reference, username, password, message): | ||
834 | 30 | """Assert that an archive auth token check returns NotFound.""" | ||
835 | 31 | fault = self.archive_api.checkArchiveAuthToken( | ||
836 | 32 | archive_reference, username, password) | ||
837 | 33 | self.assertEqual(faults.NotFound(message), fault) | ||
838 | 34 | |||
839 | 35 | def assertUnauthorized(self, archive_reference, username, password): | ||
840 | 36 | """Assert that an archive auth token check returns Unauthorized.""" | ||
841 | 37 | fault = self.archive_api.checkArchiveAuthToken( | ||
842 | 38 | archive_reference, username, password) | ||
843 | 39 | self.assertEqual(faults.Unauthorized("Authorisation required."), fault) | ||
844 | 40 | |||
845 | 41 | def test_checkArchiveAuthToken_unknown_archive(self): | ||
846 | 42 | self.assertNotFound( | ||
847 | 43 | "~nonexistent/unknown/bad", "user", "", | ||
848 | 44 | "No archive found for '~nonexistent/unknown/bad'.") | ||
849 | 45 | |||
850 | 46 | def test_checkArchiveAuthToken_no_tokens(self): | ||
851 | 47 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
852 | 48 | self.assertNotFound( | ||
853 | 49 | archive.reference, "nobody", "", | ||
854 | 50 | "No valid tokens for 'nobody' in '%s'." % archive.reference) | ||
855 | 51 | |||
856 | 52 | def test_checkArchiveAuthToken_no_named_tokens(self): | ||
857 | 53 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
858 | 54 | self.assertNotFound( | ||
859 | 55 | archive.reference, "+missing", "", | ||
860 | 56 | "No valid tokens for '+missing' in '%s'." % archive.reference) | ||
861 | 57 | |||
862 | 58 | def test_checkArchiveAuthToken_buildd_wrong_password(self): | ||
863 | 59 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
864 | 60 | self.assertUnauthorized( | ||
865 | 61 | archive.reference, "buildd", archive.buildd_secret + "-bad") | ||
866 | 62 | |||
867 | 63 | def test_checkArchiveAuthToken_buildd_correct_password(self): | ||
868 | 64 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
869 | 65 | self.assertIsNone(self.archive_api.checkArchiveAuthToken( | ||
870 | 66 | archive.reference, "buildd", archive.buildd_secret)) | ||
871 | 67 | |||
872 | 68 | def test_checkArchiveAuthToken_named_token_wrong_password(self): | ||
873 | 69 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
874 | 70 | token = archive.newNamedAuthToken("special") | ||
875 | 71 | removeSecurityProxy(token).deactivate() | ||
876 | 72 | self.assertNotFound( | ||
877 | 73 | archive.reference, "+special", token.token, | ||
878 | 74 | "No valid tokens for '+special' in '%s'." % archive.reference) | ||
879 | 75 | |||
880 | 76 | def test_checkArchiveAuthToken_named_token_deactivated(self): | ||
881 | 77 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
882 | 78 | token = archive.newNamedAuthToken("special") | ||
883 | 79 | self.assertIsNone(self.archive_api.checkArchiveAuthToken( | ||
884 | 80 | archive.reference, "+special", token.token)) | ||
885 | 81 | |||
886 | 82 | def test_checkArchiveAuthToken_named_token_correct_password(self): | ||
887 | 83 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
888 | 84 | token = archive.newNamedAuthToken("special") | ||
889 | 85 | self.assertIsNone(self.archive_api.checkArchiveAuthToken( | ||
890 | 86 | archive.reference, "+special", token.token)) | ||
891 | 87 | |||
892 | 88 | def test_checkArchiveAuthToken_personal_token_wrong_password(self): | ||
893 | 89 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
894 | 90 | subscriber = self.factory.makePerson() | ||
895 | 91 | archive.newSubscription(subscriber, archive.owner) | ||
896 | 92 | token = archive.newAuthToken(subscriber) | ||
897 | 93 | self.assertUnauthorized( | ||
898 | 94 | archive.reference, subscriber.name, token.token + "-bad") | ||
899 | 95 | |||
900 | 96 | def test_checkArchiveAuthToken_personal_token_deactivated(self): | ||
901 | 97 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
902 | 98 | subscriber = self.factory.makePerson() | ||
903 | 99 | archive.newSubscription(subscriber, archive.owner) | ||
904 | 100 | token = archive.newAuthToken(subscriber) | ||
905 | 101 | removeSecurityProxy(token).deactivate() | ||
906 | 102 | self.assertNotFound( | ||
907 | 103 | archive.reference, subscriber.name, token.token, | ||
908 | 104 | "No valid tokens for '%s' in '%s'." % ( | ||
909 | 105 | subscriber.name, archive.reference)) | ||
910 | 106 | |||
911 | 107 | def test_checkArchiveAuthToken_personal_token_cancelled(self): | ||
912 | 108 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
913 | 109 | subscriber = self.factory.makePerson() | ||
914 | 110 | subscription = archive.newSubscription(subscriber, archive.owner) | ||
915 | 111 | token = archive.newAuthToken(subscriber) | ||
916 | 112 | removeSecurityProxy(subscription).cancel(archive.owner) | ||
917 | 113 | self.assertNotFound( | ||
918 | 114 | archive.reference, subscriber.name, token.token, | ||
919 | 115 | "No valid tokens for '%s' in '%s'." % ( | ||
920 | 116 | subscriber.name, archive.reference)) | ||
921 | 117 | |||
922 | 118 | def test_checkArchiveAuthToken_personal_token_correct_password(self): | ||
923 | 119 | archive = removeSecurityProxy(self.factory.makeArchive(private=True)) | ||
924 | 120 | subscriber = self.factory.makePerson() | ||
925 | 121 | archive.newSubscription(subscriber, archive.owner) | ||
926 | 122 | token = archive.newAuthToken(subscriber) | ||
927 | 123 | self.assertIsNone(self.archive_api.checkArchiveAuthToken( | ||
928 | 124 | archive.reference, subscriber.name, token.token)) | ||
929 | 0 | 125 | ||
930 | === modified file 'lib/lp/systemhomes.py' | |||
931 | --- lib/lp/systemhomes.py 2017-05-16 16:33:53 +0000 | |||
932 | +++ lib/lp/systemhomes.py 2017-11-19 12:35:56 +0000 | |||
933 | @@ -72,6 +72,7 @@ | |||
934 | 72 | from lp.services.webapp.publisher import canonical_url | 72 | from lp.services.webapp.publisher import canonical_url |
935 | 73 | from lp.services.webservice.interfaces import IWebServiceApplication | 73 | from lp.services.webservice.interfaces import IWebServiceApplication |
936 | 74 | from lp.services.worlddata.interfaces.language import ILanguageSet | 74 | from lp.services.worlddata.interfaces.language import ILanguageSet |
937 | 75 | from lp.soyuz.interfaces.archiveapi import IArchiveApplication | ||
938 | 75 | from lp.testopenid.interfaces.server import ITestOpenIDApplication | 76 | from lp.testopenid.interfaces.server import ITestOpenIDApplication |
939 | 76 | from lp.translations.interfaces.translationgroup import ITranslationGroupSet | 77 | from lp.translations.interfaces.translationgroup import ITranslationGroupSet |
940 | 77 | from lp.translations.interfaces.translations import IRosettaApplication | 78 | from lp.translations.interfaces.translations import IRosettaApplication |
941 | @@ -80,6 +81,12 @@ | |||
942 | 80 | ) | 81 | ) |
943 | 81 | 82 | ||
944 | 82 | 83 | ||
945 | 84 | @implementer(IArchiveApplication) | ||
946 | 85 | class ArchiveApplication: | ||
947 | 86 | |||
948 | 87 | title = "Archive API" | ||
949 | 88 | |||
950 | 89 | |||
951 | 83 | @implementer(ICodehostingApplication) | 90 | @implementer(ICodehostingApplication) |
952 | 84 | class CodehostingApplication: | 91 | class CodehostingApplication: |
953 | 85 | """Codehosting End-Point.""" | 92 | """Codehosting End-Point.""" |
954 | 86 | 93 | ||
955 | === modified file 'lib/lp/xmlrpc/application.py' | |||
956 | --- lib/lp/xmlrpc/application.py 2015-10-26 14:54:43 +0000 | |||
957 | +++ lib/lp/xmlrpc/application.py 2017-11-19 12:35:56 +0000 | |||
958 | @@ -31,11 +31,12 @@ | |||
959 | 31 | from lp.services.features.xmlrpc import IFeatureFlagApplication | 31 | from lp.services.features.xmlrpc import IFeatureFlagApplication |
960 | 32 | from lp.services.webapp import LaunchpadXMLRPCView | 32 | from lp.services.webapp import LaunchpadXMLRPCView |
961 | 33 | from lp.services.webapp.interfaces import ILaunchBag | 33 | from lp.services.webapp.interfaces import ILaunchBag |
962 | 34 | from lp.soyuz.interfaces.archiveapi import IArchiveApplication | ||
963 | 34 | from lp.xmlrpc.interfaces import IPrivateApplication | 35 | from lp.xmlrpc.interfaces import IPrivateApplication |
964 | 35 | 36 | ||
965 | 36 | 37 | ||
966 | 37 | # NOTE: If you add a traversal here, you should update | 38 | # NOTE: If you add a traversal here, you should update |
968 | 38 | # the regular expression in utilities/page-performance-report.ini | 39 | # the regular expression in lp:lp-dev-utils page-performance-report.ini. |
969 | 39 | @implementer(IPrivateApplication) | 40 | @implementer(IPrivateApplication) |
970 | 40 | class PrivateApplication: | 41 | class PrivateApplication: |
971 | 41 | 42 | ||
972 | @@ -45,6 +46,11 @@ | |||
973 | 45 | return getUtility(IMailingListApplication) | 46 | return getUtility(IMailingListApplication) |
974 | 46 | 47 | ||
975 | 47 | @property | 48 | @property |
976 | 49 | def archive(self): | ||
977 | 50 | """See `IPrivateApplication`.""" | ||
978 | 51 | return getUtility(IArchiveApplication) | ||
979 | 52 | |||
980 | 53 | @property | ||
981 | 48 | def authserver(self): | 54 | def authserver(self): |
982 | 49 | """See `IPrivateApplication`.""" | 55 | """See `IPrivateApplication`.""" |
983 | 50 | return getUtility(IAuthServerApplication) | 56 | return getUtility(IAuthServerApplication) |
984 | 51 | 57 | ||
985 | === modified file 'lib/lp/xmlrpc/configure.zcml' | |||
986 | --- lib/lp/xmlrpc/configure.zcml 2015-05-04 14:56:58 +0000 | |||
987 | +++ lib/lp/xmlrpc/configure.zcml 2017-11-19 12:35:56 +0000 | |||
988 | @@ -22,6 +22,19 @@ | |||
989 | 22 | /> | 22 | /> |
990 | 23 | 23 | ||
991 | 24 | <securedutility | 24 | <securedutility |
992 | 25 | class="lp.systemhomes.ArchiveApplication" | ||
993 | 26 | provides="lp.soyuz.interfaces.archiveapi.IArchiveApplication"> | ||
994 | 27 | <allow interface="lp.soyuz.interfaces.archiveapi.IArchiveApplication"/> | ||
995 | 28 | </securedutility> | ||
996 | 29 | |||
997 | 30 | <xmlrpc:view | ||
998 | 31 | for="lp.soyuz.interfaces.archiveapi.IArchiveApplication" | ||
999 | 32 | interface="lp.soyuz.interfaces.archiveapi.IArchiveAPI" | ||
1000 | 33 | class="lp.soyuz.xmlrpc.archive.ArchiveAPI" | ||
1001 | 34 | permission="zope.Public" | ||
1002 | 35 | /> | ||
1003 | 36 | |||
1004 | 37 | <securedutility | ||
1005 | 25 | class="lp.systemhomes.CodehostingApplication" | 38 | class="lp.systemhomes.CodehostingApplication" |
1006 | 26 | provides="lp.code.interfaces.codehosting.ICodehostingApplication"> | 39 | provides="lp.code.interfaces.codehosting.ICodehostingApplication"> |
1007 | 27 | <allow interface="lp.code.interfaces.codehosting.ICodehostingApplication"/> | 40 | <allow interface="lp.code.interfaces.codehosting.ICodehostingApplication"/> |
1008 | 28 | 41 | ||
1009 | === modified file 'lib/lp/xmlrpc/interfaces.py' | |||
1010 | --- lib/lp/xmlrpc/interfaces.py 2015-05-04 14:56:58 +0000 | |||
1011 | +++ lib/lp/xmlrpc/interfaces.py 2017-11-19 12:35:56 +0000 | |||
1012 | @@ -17,6 +17,8 @@ | |||
1013 | 17 | class IPrivateApplication(ILaunchpadApplication): | 17 | class IPrivateApplication(ILaunchpadApplication): |
1014 | 18 | """Launchpad private XML-RPC application root.""" | 18 | """Launchpad private XML-RPC application root.""" |
1015 | 19 | 19 | ||
1016 | 20 | archive = Attribute("Archive XML-RPC end point.""") | ||
1017 | 21 | |||
1018 | 20 | authserver = Attribute("""Old Authserver API end point.""") | 22 | authserver = Attribute("""Old Authserver API end point.""") |
1019 | 21 | 23 | ||
1020 | 22 | codeimportscheduler = Attribute("""Code import scheduler end point.""") | 24 | codeimportscheduler = Attribute("""Code import scheduler end point.""") |
1021 | 23 | 25 | ||
1022 | === added file 'scripts/wsgi-archive-auth.py' | |||
1023 | --- scripts/wsgi-archive-auth.py 1970-01-01 00:00:00 +0000 | |||
1024 | +++ scripts/wsgi-archive-auth.py 2017-11-19 12:35:56 +0000 | |||
1025 | @@ -0,0 +1,71 @@ | |||
1026 | 1 | #!/usr/bin/python | ||
1027 | 2 | # | ||
1028 | 3 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
1029 | 4 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
1030 | 5 | |||
1031 | 6 | """WSGI archive authorisation provider entry point. | ||
1032 | 7 | |||
1033 | 8 | Unlike most Launchpad scripts, the #! line of this script does not use -S. | ||
1034 | 9 | This is because it is only executed (as opposed to imported) for testing, | ||
1035 | 10 | and mod_wsgi does not disable the automatic import of the site module when | ||
1036 | 11 | importing this script, so we want the test to imitate mod_wsgi's behaviour | ||
1037 | 12 | as closely as possible. | ||
1038 | 13 | """ | ||
1039 | 14 | |||
1040 | 15 | from __future__ import absolute_import, print_function, unicode_literals | ||
1041 | 16 | |||
1042 | 17 | __metaclass__ = type | ||
1043 | 18 | __all__ = [ | ||
1044 | 19 | 'check_password', | ||
1045 | 20 | ] | ||
1046 | 21 | |||
1047 | 22 | # mod_wsgi imports this file without a useful sys.path, so we need some | ||
1048 | 23 | # acrobatics to set ourselves up properly. | ||
1049 | 24 | import os.path | ||
1050 | 25 | import sys | ||
1051 | 26 | |||
1052 | 27 | scripts_dir = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) | ||
1053 | 28 | if scripts_dir not in sys.path: | ||
1054 | 29 | sys.path.insert(0, scripts_dir) | ||
1055 | 30 | top = os.path.dirname(scripts_dir) | ||
1056 | 31 | |||
1057 | 32 | # We can't stop mod_wsgi importing the site module. Cross fingers and | ||
1058 | 33 | # arrange for it to be re-imported. | ||
1059 | 34 | sys.modules.pop("site", None) | ||
1060 | 35 | sys.modules.pop("sitecustomize", None) | ||
1061 | 36 | |||
1062 | 37 | import _pythonpath | ||
1063 | 38 | |||
1064 | 39 | from lp.soyuz.wsgi.archiveauth import check_password | ||
1065 | 40 | |||
1066 | 41 | |||
1067 | 42 | def main(): | ||
1068 | 43 | # Hook for testing, not used by WSGI. | ||
1069 | 44 | from argparse import ArgumentParser | ||
1070 | 45 | |||
1071 | 46 | from lp.services.memcache.testing import MemcacheFixture | ||
1072 | 47 | from lp.soyuz.wsgi import archiveauth | ||
1073 | 48 | |||
1074 | 49 | parser = ArgumentParser() | ||
1075 | 50 | parser.add_argument("archive_path") | ||
1076 | 51 | parser.add_argument("username") | ||
1077 | 52 | parser.add_argument("password") | ||
1078 | 53 | args = parser.parse_args() | ||
1079 | 54 | archiveauth._memcache_client = MemcacheFixture() | ||
1080 | 55 | result = check_password( | ||
1081 | 56 | {"SCRIPT_NAME": args.archive_path}, args.username, args.password) | ||
1082 | 57 | if result is None: | ||
1083 | 58 | print("Archive or user does not exist.", file=sys.stderr) | ||
1084 | 59 | return 2 | ||
1085 | 60 | elif result is False: | ||
1086 | 61 | print("Password does not match.", file=sys.stderr) | ||
1087 | 62 | return 1 | ||
1088 | 63 | elif result is True: | ||
1089 | 64 | return 0 | ||
1090 | 65 | else: | ||
1091 | 66 | print("Unexpected result from check_password: %s" % result) | ||
1092 | 67 | return 3 | ||
1093 | 68 | |||
1094 | 69 | |||
1095 | 70 | if __name__ == "__main__": | ||
1096 | 71 | sys.exit(main()) | ||
1097 | 0 | 72 | ||
1098 | === modified file 'utilities/rocketfuel-setup' | |||
1099 | --- utilities/rocketfuel-setup 2017-01-10 17:24:08 +0000 | |||
1100 | +++ utilities/rocketfuel-setup 2017-11-19 12:35:56 +0000 | |||
1101 | @@ -189,6 +189,12 @@ | |||
1102 | 189 | exit 1 | 189 | exit 1 |
1103 | 190 | fi | 190 | fi |
1104 | 191 | 191 | ||
1105 | 192 | sudo a2enmod wsgi > /dev/null | ||
1106 | 193 | if [ $? -ne 0 ]; then | ||
1107 | 194 | echo "ERROR: Unable to enable wsgi module in Apache2" | ||
1108 | 195 | exit 1 | ||
1109 | 196 | fi | ||
1110 | 197 | |||
1111 | 192 | if [ $DO_WORKSPACE == 0 ]; then | 198 | if [ $DO_WORKSPACE == 0 ]; then |
1112 | 193 | cat <<EOT | 199 | cat <<EOT |
1113 | 194 | Branches have not been created, as requested. You will need to do some or all | 200 | Branches have not been created, as requested. You will need to do some or all |
I've rewritten part of this using memcached rather than timedcache, so the cost of using multiple workers here should now be negligible.