Merge lp:~maxiberta/canonical-identity-provider/disable-user-registration-api into lp:canonical-identity-provider/release

Proposed by Maximiliano Bertacchini
Status: Merged
Approved by: Maximiliano Bertacchini
Approved revision: no longer in the source branch.
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: lp:~maxiberta/canonical-identity-provider/disable-user-registration-api
Merge into: lp:canonical-identity-provider/release
Diff against target: 199 lines (+117/-1)
5 files modified
src/api/v10/handlers.py (+8/-0)
src/api/v10/tests/test_handlers.py (+71/-1)
src/api/v20/handlers.py (+6/-0)
src/api/v20/tests/test_handlers.py (+27/-0)
src/api/v20/utils.py (+5/-0)
To merge this branch: bzr merge lp:~maxiberta/canonical-identity-provider/disable-user-registration-api
Reviewer Review Type Date Requested Status
Natalia Bidart (community) Approve
Review via email: mp+356766@code.launchpad.net

Commit message

Disable user registration via API v1/v2 (via USER_REGISTRATION_API_ENABLED switch, disabled by default).

Description of the change

It was agreed that account registration via API should be disabled/not allowed; leaving the web UI as the only means of registration.

User registration via web internally POSTs to http://localhost/api/v2/accounts (from `settings.API_HOST = "http://localhost"`), so we need to unconditionally allow "internal" requests to this endpoint. These internal requests do not contain the X-Forwarded-For header.

The presence of the X-Forwarded-For header is used to mark a request as coming from an external client, instead of `request.environ['REMOTE_ADDR']` which is basically the 1st item of `request.environ['HTTP_X_FORWARDED_FOR']` (see src/identityprovider/middleware/http.py), and easily spoofable (should be fixed separately).

To post a comment you must log in.
Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

Some stats from sso-app/4 in PROD in the last 120 days (details: https://pastebin.canonical.com/p/JXcXz6yyY7/):

- POST /api/1.0/registration: 731 hits (broken due to dead reCaptcha v1).
- POST /api/1.1/registration: 0 hits (broken due to dead reCaptcha v1).
- POST /api/v2/accounts: 80848 hits total, 64199 successful (returning 201), of which only 424 *not* coming from 127.0.0.1.

In summary: only 424 successful account registrations via API in the last 4 months (in this host out of 4 units total).

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

18:47 < nessita> maxiberta, thank you, would you have any user agent or any info about those 424 attempts?
18:47 < nessita> maxiberta, are they "ubuntuone clients" for example?
18:49 < maxiberta> nessita: 59 are "Mozilla/5.0", 365 are "-"

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

+1 with a minor nitpick

review: Approve
Revision history for this message
Maximiliano Bertacchini (maxiberta) :
Revision history for this message
Natalia Bidart (nataliabidart) wrote :

+1 with a small nitpick

review: Approve
Revision history for this message
Maximiliano Bertacchini (maxiberta) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/api/v10/handlers.py'
2--- src/api/v10/handlers.py 2018-10-12 21:03:41 +0000
3+++ src/api/v10/handlers.py 2018-10-22 14:58:19 +0000
4@@ -17,6 +17,7 @@
5 from django.shortcuts import render
6 from django.utils.timezone import now
7 from django.utils.translation import ugettext_lazy as _
8+from gargoyle import gargoyle
9 from oauth_backend.models import Token
10 from piston.emitters import Emitter
11 from piston.handler import BaseHandler
12@@ -178,6 +179,13 @@
13
14 @named_operation
15 def register(self, request):
16+ if not gargoyle.is_active('USER_REGISTRATION_API_ENABLED', request):
17+ # The presence X-Forwarded-For marks an external client's request.
18+ # Internal requests (e.g. from the new_account view) are allowed.
19+ if 'HTTP_X_FORWARDED_FOR' in request.environ:
20+ return api_error(HttpResponseForbidden,
21+ "User registration via API is disabled.")
22+
23 data = request.data
24 data['remote_ip'] = request.environ['REMOTE_ADDR']
25 form = WebserviceCreateAccountForm(data)
26
27=== modified file 'src/api/v10/tests/test_handlers.py'
28--- src/api/v10/tests/test_handlers.py 2018-10-12 21:03:41 +0000
29+++ src/api/v10/tests/test_handlers.py 2018-10-22 14:58:19 +0000
30@@ -59,6 +59,8 @@
31
32 class RegistrationHandlerTestCase(AnonAPITestCase):
33
34+ url = reverse('api-10-registration')
35+
36 def test_authtoken_without_requester_is_handled_properly(self):
37 authtoken = self.factory.make_authtoken(
38 email="test@example.com",
39@@ -380,13 +382,81 @@
40
41 with self.assertRaises(Exception):
42 self.client.post(
43- '/api/1.0/registration',
44+ self.url,
45 {'ws.op': 'register',
46 'email': email_address,
47 'password': 'MySecretPassword1',
48 'captcha_solution': 'foobar',
49 'captcha_id': 'id'})
50
51+ @switches(USER_REGISTRATION_API_ENABLED=False)
52+ def test_feature_flag_disabled_with_internal_client(self):
53+ # Request has *no* X-Forwarded-For, which is treated as an internal IP.
54+ email = self.factory.make_email_address()
55+ data = {
56+ 'ws.op': 'register', 'displayname': 'Test User', 'email': email,
57+ 'password': 'MySecretPassword1',
58+ 'captcha_solution': 'foobar', 'captcha_id': 'id',
59+ }
60+
61+ response = self.client.get(self.url, data)
62+
63+ self.assertEqual(response.status_code, 200)
64+ account = Account.objects.get_by_email(email)
65+ self.assertEqual(account.displayname, 'Test User')
66+ self.assert_new_user_email_sent(email)
67+
68+ @switches(USER_REGISTRATION_API_ENABLED=False)
69+ def test_feature_flag_disabled_with_external_client(self):
70+ # Request has X-Forwarded-For, which is treated as an *external* IP.
71+ email = self.factory.make_email_address()
72+ data = {
73+ 'ws.op': 'register', 'displayname': 'Test User', 'email': email,
74+ 'password': 'MySecretPassword1',
75+ 'captcha_solution': 'foobar', 'captcha_id': 'id',
76+ }
77+ headers = {'HTTP_X_FORWARDED_FOR': '127.0.0.1'}
78+
79+ response = self.client.get(self.url, data, **headers)
80+
81+ self.assertEqual(response.status_code, 403)
82+ self.assertIsNone(Account.objects.get_by_email(email))
83+
84+ @switches(USER_REGISTRATION_API_ENABLED=True)
85+ def test_feature_flag_enabled_with_internal_client(self):
86+ # Request has *no* X-Forwarded-For, which is treated as an internal IP.
87+ email = self.factory.make_email_address()
88+ data = {
89+ 'ws.op': 'register', 'displayname': 'Test User', 'email': email,
90+ 'password': 'MySecretPassword1',
91+ 'captcha_solution': 'foobar', 'captcha_id': 'id',
92+ }
93+
94+ response = self.client.get(self.url, data)
95+
96+ self.assertEqual(response.status_code, 200)
97+ account = Account.objects.get_by_email(email)
98+ self.assertEqual(account.displayname, 'Test User')
99+ self.assert_new_user_email_sent(email)
100+
101+ @switches(USER_REGISTRATION_API_ENABLED=True)
102+ def test_feature_flag_enabled_with_external_client(self):
103+ # Request has X-Forwarded-For, which is treated as an *external* IP.
104+ email = self.factory.make_email_address()
105+ data = {
106+ 'ws.op': 'register', 'displayname': 'Test User', 'email': email,
107+ 'password': 'MySecretPassword1',
108+ 'captcha_solution': 'foobar', 'captcha_id': 'id',
109+ }
110+ headers = {'HTTP_X_FORWARDED_FOR': '127.0.0.1'}
111+
112+ response = self.client.get(self.url, data, **headers)
113+
114+ self.assertEqual(response.status_code, 200)
115+ account = Account.objects.get_by_email(email)
116+ self.assertEqual(account.displayname, 'Test User')
117+ self.assert_new_user_email_sent(email)
118+
119
120 class AuthenticationTestCase(SSOBaseTestCase):
121
122
123=== modified file 'src/api/v20/handlers.py'
124--- src/api/v20/handlers.py 2018-08-17 18:38:29 +0000
125+++ src/api/v20/handlers.py 2018-10-22 14:58:19 +0000
126@@ -282,6 +282,12 @@
127 @throttle()
128 def create(self, request):
129 """Create/register a new account."""
130+ if not gargoyle.is_active('USER_REGISTRATION_API_ENABLED', request):
131+ # The presence X-Forwarded-For marks an external client's request.
132+ # Internal requests (e.g. from the new_account view) are allowed.
133+ if 'HTTP_X_FORWARDED_FOR' in request.environ:
134+ return errors.FEATURE_DISABLED()
135+
136 data = request.data
137
138 try:
139
140=== modified file 'src/api/v20/tests/test_handlers.py'
141--- src/api/v20/tests/test_handlers.py 2018-09-13 20:19:21 +0000
142+++ src/api/v20/tests/test_handlers.py 2018-10-22 14:58:19 +0000
143@@ -616,6 +616,33 @@
144 self.assert_bad_request(
145 data=self.data, extra={'email': ['Invalid email']})
146
147+ @switches(USER_REGISTRATION_API_ENABLED=False)
148+ def test_feature_flag_disabled_with_internal_client(self):
149+ # Request has *no* X-Forwarded-For, which is treated as an internal IP.
150+ json_body = self.do_post(self.data, status_code=201)
151+ self.assert_correct_account_information(json_body)
152+
153+ @switches(USER_REGISTRATION_API_ENABLED=False)
154+ def test_feature_flag_disabled_with_external_client(self):
155+ # Request has X-Forwarded-For, which is treated as an *external* IP.
156+ headers = {'HTTP_X_FORWARDED_FOR': '127.0.0.1'}
157+ json_body = self.do_post(self.data, status_code=403, **headers)
158+ self.assertEqual(json_body['code'], "FEATURE_DISABLED")
159+ self.assertEqual("Feature disabled.", json_body['message'])
160+
161+ @switches(USER_REGISTRATION_API_ENABLED=True)
162+ def test_feature_flag_enabled_with_internal_client(self):
163+ # Request has *no* X-Forwarded-For, which is treated as an internal IP.
164+ json_body = self.do_post(self.data, status_code=201)
165+ self.assert_correct_account_information(json_body)
166+
167+ @switches(USER_REGISTRATION_API_ENABLED=True)
168+ def test_feature_flag_enabled_with_external_client(self):
169+ # Request has X-Forwarded-For, which is treated as an *external* IP.
170+ headers = {'HTTP_X_FORWARDED_FOR': '127.0.0.1'}
171+ json_body = self.do_post(self.data, status_code=201, **headers)
172+ self.assert_correct_account_information(json_body)
173+
174
175 @override_settings(CAPTCHA_PUBLIC_KEY='public', CAPTCHA_PRIVATE_KEY='private')
176 class AnonymousAccountRegistrationWithCaptchaHandlerTestCase(BaseTestCase):
177
178=== modified file 'src/api/v20/utils.py'
179--- src/api/v20/utils.py 2018-05-28 17:22:45 +0000
180+++ src/api/v20/utils.py 2018-10-22 14:58:19 +0000
181@@ -67,6 +67,7 @@
182 class ErrorCode:
183 ACCOUNT_NOT_READY = 'account-not-ready'
184 BAD_REQUEST = 'bad-request'
185+ FEATURE_DISABLED = 'feature-disabled'
186 INTERNAL_ERROR = 'internal-server-error'
187 INVALID_DATA = 'invalid-data'
188 INVALID_CREDENTIALS = 'invalid-credentials'
189@@ -141,6 +142,10 @@
190 403, _("Insufficient permissions."),
191 ErrorCode.PERMISSION_REQUIRED),
192
193+ FEATURE_DISABLED=(
194+ 403, _("Feature disabled."),
195+ ErrorCode.FEATURE_DISABLED),
196+
197 RESOURCE_NOT_FOUND=(
198 404, _("The resource requested was not found."),
199 ErrorCode.RESOURCE_NOT_FOUND),