Implement APIKey as django form + recaptcha field (#22759)
* API Key form with recaptcha and more susinct rendering * Fix name variable in help text
This commit is contained in:
Родитель
00565b80ab
Коммит
f8e7fa2958
|
@ -1,6 +1,7 @@
|
|||
import os
|
||||
import tarfile
|
||||
import zipfile
|
||||
from functools import cached_property
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django import forms
|
||||
|
@ -17,6 +18,7 @@ from django.utils.translation import gettext, gettext_lazy as _, ngettext
|
|||
|
||||
import waffle
|
||||
from django_statsd.clients import statsd
|
||||
from extended_choices import Choices
|
||||
|
||||
from olympia import amo
|
||||
from olympia.access import acl
|
||||
|
@ -42,6 +44,7 @@ from olympia.amo.forms import AMOModelForm
|
|||
from olympia.amo.messages import DoubleSafe
|
||||
from olympia.amo.utils import slug_validator, verify_no_urls
|
||||
from olympia.amo.validators import OneOrMoreLetterOrNumberCharacterValidator
|
||||
from olympia.api.models import APIKey, APIKeyConfirmation
|
||||
from olympia.api.throttling import CheckThrottlesFormMixin, addon_submission_throttles
|
||||
from olympia.applications.models import AppVersion
|
||||
from olympia.constants.categories import CATEGORIES, CATEGORIES_BY_ID
|
||||
|
@ -1424,3 +1427,154 @@ class AgreementForm(forms.Form):
|
|||
if not checker.is_submission_allowed(check_dev_agreement=False):
|
||||
raise forms.ValidationError(checker.get_error_message())
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class APIKeyForm(forms.Form):
|
||||
ACTION_CHOICES = Choices(
|
||||
('confirm', 'confirm', _('Confirm email address')),
|
||||
('generate', 'generate', _('Generate new credentials')),
|
||||
('regenerate', 'regenerate', _('Revoke and regenerate credentials')),
|
||||
('revoke', 'revoke', _('Revoke')),
|
||||
)
|
||||
REQUIRES_CREDENTIALS = (ACTION_CHOICES.revoke, ACTION_CHOICES.regenerate)
|
||||
REQUIRES_CONFIRMATION = (ACTION_CHOICES.generate, ACTION_CHOICES.regenerate)
|
||||
|
||||
@cached_property
|
||||
def credentials(self):
|
||||
try:
|
||||
return APIKey.get_jwt_key(user=self.request.user)
|
||||
except APIKey.DoesNotExist:
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def confirmation(self):
|
||||
try:
|
||||
return APIKeyConfirmation.objects.get(user=self.request.user)
|
||||
except APIKeyConfirmation.DoesNotExist:
|
||||
return None
|
||||
|
||||
def validate_confirmation_token(self, value):
|
||||
if (
|
||||
not self.confirmation.confirmed_once
|
||||
and not self.confirmation.is_token_valid(value)
|
||||
):
|
||||
raise forms.ValidationError('Invalid token')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.request = kwargs.pop('request', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.action = self.data.get('action', None)
|
||||
self.available_actions = []
|
||||
|
||||
# Available actions determine what you can do currently
|
||||
has_credentials = self.credentials is not None
|
||||
has_confirmation = self.confirmation is not None
|
||||
|
||||
# User has credentials, show them and offer to revoke/regenerate
|
||||
if has_credentials:
|
||||
self.fields['credentials_key'] = forms.CharField(
|
||||
label=_('JWT issuer'),
|
||||
max_length=255,
|
||||
disabled=True,
|
||||
widget=forms.TextInput(attrs={'readonly': True}),
|
||||
required=True,
|
||||
initial=self.credentials.key,
|
||||
help_text=_(
|
||||
'To make API requests, send a <a href="{jwt_url}">'
|
||||
'JSON Web Token (JWT)</a> as the authorization header. '
|
||||
"You'll need to generate a JWT for every request as explained in "
|
||||
'the <a href="{docs_url}">API documentation</a>.'
|
||||
).format(
|
||||
jwt_url='https://self-issued.info/docs/draft-ietf-oauth-json-web-token.html',
|
||||
docs_url='https://addons-server.readthedocs.io/en/latest/topics/api/auth.html',
|
||||
),
|
||||
)
|
||||
self.fields['credentials_secret'] = forms.CharField(
|
||||
label=_('JWT secret'),
|
||||
max_length=255,
|
||||
disabled=True,
|
||||
widget=forms.TextInput(attrs={'readonly': True}),
|
||||
required=True,
|
||||
initial=self.credentials.secret,
|
||||
)
|
||||
self.available_actions.append(self.ACTION_CHOICES.revoke)
|
||||
|
||||
if has_confirmation and self.confirmation.confirmed_once:
|
||||
self.available_actions.append(self.ACTION_CHOICES.regenerate)
|
||||
|
||||
elif has_confirmation:
|
||||
get_token_param = self.request.GET.get('token')
|
||||
|
||||
if (
|
||||
self.confirmation.confirmed_once
|
||||
or get_token_param is not None
|
||||
or self.data.get('confirmation_token') is not None
|
||||
):
|
||||
help_text = _(
|
||||
'Please click the confirm button below to generate '
|
||||
'API credentials for user <strong>{name}</strong>.'
|
||||
).format(name=self.request.user.name)
|
||||
self.available_actions.append(self.ACTION_CHOICES.generate)
|
||||
else:
|
||||
help_text = _(
|
||||
'A confirmation link will be sent to your email address. '
|
||||
'After confirmation you will find your API keys on this page.'
|
||||
)
|
||||
|
||||
self.fields['confirmation_token'] = forms.CharField(
|
||||
label='',
|
||||
max_length=20,
|
||||
widget=forms.HiddenInput(),
|
||||
initial=get_token_param,
|
||||
required=False,
|
||||
help_text=help_text,
|
||||
validators=[self.validate_confirmation_token],
|
||||
)
|
||||
|
||||
else:
|
||||
if waffle.switch_is_active('developer-submit-addon-captcha'):
|
||||
self.fields['recaptcha'] = ReCaptchaField(
|
||||
label='', help_text=_("You don't have any API credentials.")
|
||||
)
|
||||
self.available_actions.append(self.ACTION_CHOICES.confirm)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# The actions available depend on the current state
|
||||
# and are determined during initialization
|
||||
if self.action not in self.available_actions:
|
||||
raise forms.ValidationError(
|
||||
_('Something went wrong, please contact developer support.')
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self):
|
||||
credentials_revoked = False
|
||||
credentials_generated = False
|
||||
confirmation_created = False
|
||||
|
||||
# User is revoking or regenerating credentials, revoke existing credentials
|
||||
if self.action in self.REQUIRES_CREDENTIALS:
|
||||
self.credentials.update(is_active=None)
|
||||
credentials_revoked = True
|
||||
|
||||
# user is trying to generate or regenerate credentials, create new credentials
|
||||
if self.action in self.REQUIRES_CONFIRMATION:
|
||||
self.confirmation.update(confirmed_once=True)
|
||||
self.credentials = APIKey.new_jwt_credentials(self.request.user)
|
||||
credentials_generated = True
|
||||
|
||||
# user has no credentials or confirmation, create a confirmation
|
||||
if self.action == self.ACTION_CHOICES.confirm:
|
||||
self.confirmation = APIKeyConfirmation.objects.create(
|
||||
user=self.request.user, token=APIKeyConfirmation.generate_token()
|
||||
)
|
||||
confirmation_created = True
|
||||
|
||||
return {
|
||||
'credentials_revoked': credentials_revoked,
|
||||
'credentials_generated': credentials_generated,
|
||||
'confirmation_created': confirmation_created,
|
||||
}
|
||||
|
|
|
@ -9,84 +9,44 @@
|
|||
|
||||
<section class="primary full">
|
||||
<div class="island prettyform row">
|
||||
<form method="post" class="item api-credentials">
|
||||
<form method="post" class="item api-credentials" name="api-credentials-form">
|
||||
{% csrf_token %}
|
||||
{% if '__all__' in form.errors %}
|
||||
<div class="text-danger">
|
||||
{{ form.errors.__all__ }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<fieldset>
|
||||
<legend>
|
||||
{{ _('API Credentials') }}
|
||||
</legend>
|
||||
{% if credentials %}
|
||||
<p>
|
||||
{% trans
|
||||
docs_url='https://addons-server.readthedocs.io/en/latest/topics/api/index.html' %}
|
||||
For detailed instructions, consult the <a href="{{ docs_url }}">API documentation</a>.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<p class="notification-box error">
|
||||
{{ _('Keep your API keys secret and <strong>never share them with anyone</strong>, including Mozilla contributors.') }}
|
||||
</p>
|
||||
<ul class="api-credentials">
|
||||
<li class="row api-input key-input">
|
||||
<label for="jwtkey" class="row">{{ _('JWT issuer') }}</label>
|
||||
<input type="text" name="jwtkey" value="{{ credentials.key }}" readonly/>
|
||||
</li>
|
||||
<li class="row api-input">
|
||||
<label for="jwtsecret" class="row">{{ _('JWT secret') }}</label>
|
||||
<input type="text" name="jwtsecret" value="{{ credentials.secret }}" readonly/>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
{% trans
|
||||
docs_url='https://addons-server.readthedocs.io/en/latest/topics/api/auth.html',
|
||||
jwt_url='https://self-issued.info/docs/draft-ietf-oauth-json-web-token.html' %}
|
||||
To make API requests, send a <a href="{{ jwt_url }}">JSON Web Token (JWT)</a> as the authorization header.
|
||||
You'll need to generate a JWT for every request as explained in the
|
||||
<a href="{{ docs_url }}">API documentation</a>.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
{% elif confirmation and not confirmation.confirmed_once %}
|
||||
{% if token %}
|
||||
<p>
|
||||
{% trans name=request.user.name %}
|
||||
Please click the confirm button below to generate API credentials for user <strong>{{ name }}</strong>.
|
||||
{% endtrans %}
|
||||
<input type="hidden" name="confirmation_token" value="{{ token }}" />
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans %}
|
||||
A confirmation link will be sent to your email address. After confirmation you will find your API keys on this page.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans %}
|
||||
You don't have any API credentials.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% for field in form %}
|
||||
<div class="row api-input key-input">
|
||||
{% if field.help_text %}
|
||||
<div>{{ field.help_text|format_html }}</div>
|
||||
{% endif %}
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.errors %}
|
||||
<div class="text-danger">
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<div class="listing-footer">
|
||||
<p class="footer-submit">
|
||||
{% if credentials %}
|
||||
<button id="revoke-key" class="button prominent" type="submit" name="action" value="revoke">
|
||||
{{ _('Revoke') }}
|
||||
{% for action in form.available_actions %}
|
||||
<button
|
||||
class="button prominent"
|
||||
type="submit"
|
||||
name="action"
|
||||
value="{{ action }}"
|
||||
>
|
||||
{{ form.ACTION_CHOICES.for_value(action).display }}
|
||||
</button>
|
||||
<button id="generate-key" class="button prominent" type="submit" name="action" value="generate">
|
||||
{{ _('Revoke and regenerate credentials') }}
|
||||
</button>
|
||||
{% elif confirmation and not confirmation.confirmed_once %}
|
||||
{% if token %}
|
||||
<button id="generate-key" class="button prominent" type="submit" name="action" value="generate">
|
||||
{{ _('Confirm and generate new credentials') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<button id="generate-key" class="button prominent" type="submit" name="action" value="generate">
|
||||
{{ _('Generate new credentials') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -25,6 +25,7 @@ from olympia.amo.tests import (
|
|||
)
|
||||
from olympia.amo.tests.test_helpers import get_image_path
|
||||
from olympia.amo.utils import rm_local_tmp_dir
|
||||
from olympia.api.models import APIKey, APIKeyConfirmation
|
||||
from olympia.applications.models import AppVersion
|
||||
from olympia.constants.promoted import RECOMMENDED
|
||||
from olympia.devhub import forms
|
||||
|
@ -1281,3 +1282,166 @@ class TestAddonFormTechnical(TestCase):
|
|||
'Ensure this value has at most 3000 characters (it has 3001).'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class TestAPIKeyForm(TestCase):
|
||||
def setUp(self):
|
||||
self.user = user_factory()
|
||||
|
||||
def _request(self, action=None, token=None, data=None):
|
||||
url = '/' if token is None else f'/?token={token}'
|
||||
data = {} if data is None else data
|
||||
if action is not None:
|
||||
data['action'] = action
|
||||
return req_factory_factory(url, post=True, user=self.user, data=data)
|
||||
|
||||
def test_fields_without_credentials_or_confirmation(self):
|
||||
request = self._request(action=forms.APIKeyForm.ACTION_CHOICES.confirm)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert form.available_actions == [
|
||||
forms.APIKeyForm.ACTION_CHOICES.confirm,
|
||||
]
|
||||
assert form.is_valid()
|
||||
assert 'credentials_key' not in form.fields
|
||||
assert 'credentials_secret' not in form.fields
|
||||
assert 'confirmation_token' not in form.fields
|
||||
assert 'recaptcha' not in form.fields
|
||||
|
||||
with override_switch('developer-submit-addon-captcha', active=True):
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
recaptcha = form.fields['recaptcha']
|
||||
self.assertEqual(recaptcha.label, '')
|
||||
self.assertEqual(recaptcha.help_text, "You don't have any API credentials.")
|
||||
self.assertEqual(recaptcha.required, True)
|
||||
assert not form.is_valid()
|
||||
assert form.errors['recaptcha'] == ['This field is required.']
|
||||
|
||||
request = self._request(
|
||||
action=forms.APIKeyForm.ACTION_CHOICES.confirm,
|
||||
data={'g-recaptcha-response': 'test'},
|
||||
)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert form.is_valid()
|
||||
|
||||
def test_fields_with_credentials(self):
|
||||
APIKey.new_jwt_credentials(self.user)
|
||||
credentials = APIKey.get_jwt_key(user=self.user)
|
||||
request = self._request(action=forms.APIKeyForm.ACTION_CHOICES.revoke)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert 'confirmation_token' not in form.fields
|
||||
assert 'recaptcha' not in form.fields
|
||||
|
||||
key = form.fields['credentials_key']
|
||||
self.assertEqual(key.label, 'JWT issuer')
|
||||
self.assertEqual(key.disabled, True)
|
||||
self.assertEqual(key.widget.attrs['readonly'], True)
|
||||
self.assertEqual(key.required, True)
|
||||
self.assertEqual(key.initial, credentials.key)
|
||||
assert 'To make API requests' in key.help_text
|
||||
|
||||
secret = form.fields['credentials_secret']
|
||||
self.assertEqual(secret.label, 'JWT secret')
|
||||
self.assertEqual(secret.disabled, True)
|
||||
self.assertEqual(secret.widget.attrs['readonly'], True)
|
||||
self.assertEqual(secret.required, True)
|
||||
self.assertEqual(secret.initial, credentials.secret)
|
||||
|
||||
assert form.is_valid()
|
||||
assert form.available_actions == [
|
||||
forms.APIKeyForm.ACTION_CHOICES.revoke,
|
||||
]
|
||||
|
||||
def test_fields_with_confirmation(self):
|
||||
confirmation = APIKeyConfirmation.objects.create(user=self.user, token='test')
|
||||
request = self._request(action=forms.APIKeyForm.ACTION_CHOICES.generate)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert 'credentials_key' not in form.fields
|
||||
assert 'credentials_secret' not in form.fields
|
||||
assert 'recaptcha' not in form.fields
|
||||
|
||||
confirmation_token = form.fields['confirmation_token']
|
||||
self.assertEqual(confirmation_token.label, '')
|
||||
self.assertEqual(confirmation_token.max_length, 20)
|
||||
self.assertEqual(confirmation_token.required, False)
|
||||
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert not form.is_valid()
|
||||
assert form.available_actions == []
|
||||
|
||||
request = self._request(
|
||||
action=forms.APIKeyForm.ACTION_CHOICES.generate,
|
||||
token=confirmation.token,
|
||||
)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert form.is_valid()
|
||||
assert form.available_actions == [
|
||||
forms.APIKeyForm.ACTION_CHOICES.generate,
|
||||
]
|
||||
|
||||
confirmation.update(confirmed_once=True)
|
||||
request = self._request(
|
||||
action=forms.APIKeyForm.ACTION_CHOICES.generate,
|
||||
token=None,
|
||||
)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert form.is_valid()
|
||||
assert form.available_actions == [
|
||||
forms.APIKeyForm.ACTION_CHOICES.generate,
|
||||
]
|
||||
|
||||
def test_fields_with_credentials_and_confirmation(self):
|
||||
APIKey.new_jwt_credentials(self.user)
|
||||
confirmation = APIKeyConfirmation.objects.create(
|
||||
user=self.user, token='test', confirmed_once=True
|
||||
)
|
||||
request = self._request(action=forms.APIKeyForm.ACTION_CHOICES.revoke)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert 'recaptcha' not in form.fields
|
||||
assert 'confirmation_token' not in form.fields
|
||||
assert 'credentials_key' in form.fields
|
||||
assert 'credentials_secret' in form.fields
|
||||
assert form.is_valid()
|
||||
assert form.available_actions == [
|
||||
forms.APIKeyForm.ACTION_CHOICES.revoke,
|
||||
forms.APIKeyForm.ACTION_CHOICES.regenerate,
|
||||
]
|
||||
|
||||
confirmation.update(confirmed_once=False)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert form.is_valid()
|
||||
assert form.available_actions == [
|
||||
forms.APIKeyForm.ACTION_CHOICES.revoke,
|
||||
]
|
||||
|
||||
def test_revoke_credentials(self):
|
||||
APIKey.new_jwt_credentials(self.user)
|
||||
credentials = APIKey.get_jwt_key(user=self.user)
|
||||
assert credentials.is_active is True
|
||||
request = self._request(action=forms.APIKeyForm.ACTION_CHOICES.revoke)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
assert credentials.reload().is_active is None
|
||||
|
||||
def test_generate_credentials(self):
|
||||
APIKeyConfirmation.objects.create(user=self.user, token='test')
|
||||
confirmation = APIKeyConfirmation.objects.get(user=self.user)
|
||||
assert confirmation.confirmed_once is False
|
||||
request = self._request(
|
||||
action=forms.APIKeyForm.ACTION_CHOICES.generate, token=confirmation.token
|
||||
)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
assert confirmation.reload().confirmed_once is True
|
||||
assert form.credentials == APIKey.get_jwt_key(user=self.user)
|
||||
|
||||
def test_send_confirmation(self):
|
||||
request = self._request(action=forms.APIKeyForm.ACTION_CHOICES.confirm)
|
||||
form = forms.APIKeyForm(request.POST, request=request)
|
||||
assert form.confirmation is None
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
confirmation = APIKeyConfirmation.objects.get(user=self.user)
|
||||
assert confirmation.token is not None
|
||||
assert confirmation.user == self.user
|
||||
|
|
|
@ -38,6 +38,7 @@ from olympia.api.models import SYMMETRIC_JWT_TYPE, APIKey, APIKeyConfirmation
|
|||
from olympia.applications.models import AppVersion
|
||||
from olympia.constants.promoted import RECOMMENDED
|
||||
from olympia.devhub.decorators import dev_required
|
||||
from olympia.devhub.forms import APIKeyForm
|
||||
from olympia.devhub.models import BlogPost
|
||||
from olympia.devhub.tasks import validate
|
||||
from olympia.devhub.views import get_next_version_number
|
||||
|
@ -878,6 +879,7 @@ class TestDeveloperAgreement(TestCase):
|
|||
assert 'agreement_form' in response.context
|
||||
|
||||
|
||||
@override_switch('developer-submit-addon-captcha', active=True)
|
||||
class TestAPIKeyPage(TestCase):
|
||||
fixtures = ['base/addon_3615', 'base/users']
|
||||
|
||||
|
@ -889,6 +891,12 @@ class TestAPIKeyPage(TestCase):
|
|||
self.user.update(last_login_ip='192.168.1.1')
|
||||
self.create_flag('2fa-enforcement-for-developers-and-special-users')
|
||||
|
||||
def _submit_actions(self, doc):
|
||||
return doc('form[name=api-credentials-form] button[type=submit][name=action]')
|
||||
|
||||
def _inputs(self, doc):
|
||||
return doc('form[name=api-credentials-form] input')
|
||||
|
||||
def test_key_redirect(self):
|
||||
self.user.update(read_dev_agreement=None)
|
||||
response = self.client.get(reverse('devhub.api_key'))
|
||||
|
@ -917,13 +925,13 @@ class TestAPIKeyPage(TestCase):
|
|||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
doc = pq(response.content)
|
||||
submit = doc('#generate-key')
|
||||
assert submit.text() == 'Generate new credentials'
|
||||
inputs = doc('.api-input input')
|
||||
assert len(inputs) == 0, 'Inputs should be absent before keys exist'
|
||||
assert not doc('input[name=confirmation_token]')
|
||||
form = response.context['form']
|
||||
assert 'recaptcha' in form.fields
|
||||
(confirm_button,) = self._submit_actions(doc)
|
||||
assert 'Confirm email address' in confirm_button.text
|
||||
assert confirm_button.get('value') == APIKeyForm.ACTION_CHOICES.confirm
|
||||
|
||||
def test_view_with_credentials(self):
|
||||
def test_view_with_credentials_not_confirmed_yet(self):
|
||||
APIKey.objects.create(
|
||||
user=self.user,
|
||||
type=SYMMETRIC_JWT_TYPE,
|
||||
|
@ -933,11 +941,38 @@ class TestAPIKeyPage(TestCase):
|
|||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
doc = pq(response.content)
|
||||
submit = doc('#generate-key')
|
||||
assert submit.text() == 'Revoke and regenerate credentials'
|
||||
assert doc('#revoke-key').text() == 'Revoke'
|
||||
key_input = doc('.key-input input').val()
|
||||
assert key_input == 'some-jwt-key'
|
||||
form = response.context['form']
|
||||
assert 'credentials_key' in form.fields
|
||||
assert 'credentials_secret' in form.fields
|
||||
(revoke_button,) = self._submit_actions(doc)
|
||||
|
||||
assert 'Revoke' in revoke_button.text
|
||||
assert revoke_button.get('value') == APIKeyForm.ACTION_CHOICES.revoke
|
||||
|
||||
def test_view_with_credentials_confirmed(self):
|
||||
APIKeyConfirmation.objects.create(
|
||||
user=self.user, token='doesnt matter', confirmed_once=True
|
||||
)
|
||||
APIKey.objects.create(
|
||||
user=self.user,
|
||||
type=SYMMETRIC_JWT_TYPE,
|
||||
key='some-jwt-key',
|
||||
secret='some-jwt-secret',
|
||||
)
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
doc = pq(response.content)
|
||||
form = response.context['form']
|
||||
assert 'credentials_key' in form.fields
|
||||
assert 'credentials_secret' in form.fields
|
||||
|
||||
revoke_button, regenerate_button = self._submit_actions(doc)
|
||||
|
||||
assert 'Revoke' in revoke_button.text
|
||||
assert revoke_button.get('value') == APIKeyForm.ACTION_CHOICES.revoke
|
||||
|
||||
assert 'Revoke and regenerate credentials' in regenerate_button.text
|
||||
assert regenerate_button.get('value') == APIKeyForm.ACTION_CHOICES.regenerate
|
||||
|
||||
def test_view_without_credentials_confirmation_requested_no_token(self):
|
||||
APIKeyConfirmation.objects.create(
|
||||
|
@ -946,11 +981,13 @@ class TestAPIKeyPage(TestCase):
|
|||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
doc = pq(response.content)
|
||||
# Since confirmation has already been requested, there shouldn't be
|
||||
# any buttons on the page if no token was passed in the URL - the user
|
||||
# needs to follow the link in the email to continue.
|
||||
assert not doc('input[name=confirmation_token]')
|
||||
assert not doc('input[name=action]')
|
||||
form = response.context['form']
|
||||
assert 'confirmation_token' in form.fields
|
||||
|
||||
_, confirmation_token = self._inputs(doc)
|
||||
assert confirmation_token.get('value') is None
|
||||
|
||||
assert len(self._submit_actions(doc)) == 0
|
||||
|
||||
def test_view_without_credentials_confirmation_requested_with_token(self):
|
||||
APIKeyConfirmation.objects.create(
|
||||
|
@ -960,28 +997,36 @@ class TestAPIKeyPage(TestCase):
|
|||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
doc = pq(response.content)
|
||||
assert len(doc('input[name=confirmation_token]')) == 1
|
||||
token_input = doc('input[name=confirmation_token]')[0]
|
||||
assert token_input.value == 'secrettoken'
|
||||
submit = doc('#generate-key')
|
||||
assert submit.text() == 'Confirm and generate new credentials'
|
||||
form = response.context['form']
|
||||
assert 'confirmation_token' in form.fields
|
||||
|
||||
_, confirmation_token = self._inputs(doc)
|
||||
assert confirmation_token.value == 'secrettoken'
|
||||
|
||||
(generate_button,) = self._submit_actions(doc)
|
||||
assert 'Generate new credentials' in generate_button.text
|
||||
assert generate_button.get('value') == APIKeyForm.ACTION_CHOICES.generate
|
||||
|
||||
def test_view_no_credentials_has_been_confirmed_once(self):
|
||||
APIKeyConfirmation.objects.create(
|
||||
user=self.user, token='doesnt matter', confirmed_once=True
|
||||
)
|
||||
# Should look similar to when there are no credentials and no
|
||||
# confirmation has been requested yet, the post action is where it
|
||||
# will differ.
|
||||
self.test_view_without_credentials_not_confirmed_yet()
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
doc = pq(response.content)
|
||||
(confirm_button,) = self._submit_actions(doc)
|
||||
assert 'Generate new credentials' in confirm_button.text
|
||||
assert confirm_button.get('value') == APIKeyForm.ACTION_CHOICES.generate
|
||||
|
||||
def test_create_new_credentials_has_been_confirmed_once(self):
|
||||
APIKeyConfirmation.objects.create(
|
||||
user=self.user, token='doesnt matter', confirmed_once=True
|
||||
)
|
||||
patch = mock.patch('olympia.devhub.views.APIKey.new_jwt_credentials')
|
||||
patch = mock.patch('olympia.devhub.forms.APIKey.new_jwt_credentials')
|
||||
with patch as mock_creator:
|
||||
response = self.client.post(self.url, data={'action': 'generate'})
|
||||
response = self.client.post(
|
||||
self.url, data={'action': APIKeyForm.ACTION_CHOICES.generate}
|
||||
)
|
||||
mock_creator.assert_called_with(self.user)
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
|
@ -996,14 +1041,15 @@ class TestAPIKeyPage(TestCase):
|
|||
confirmation = APIKeyConfirmation.objects.create(
|
||||
user=self.user, token='secrettoken', confirmed_once=False
|
||||
)
|
||||
patch = mock.patch('olympia.devhub.views.APIKey.new_jwt_credentials')
|
||||
with patch as mock_creator:
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data={'action': 'generate', 'confirmation_token': 'secrettoken'},
|
||||
)
|
||||
mock_creator.assert_called_with(self.user)
|
||||
|
||||
assert not APIKey.objects.filter(user=self.user).exists()
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data={
|
||||
'action': APIKeyForm.ACTION_CHOICES.generate,
|
||||
'confirmation_token': 'secrettoken',
|
||||
},
|
||||
)
|
||||
assert APIKey.objects.filter(user=self.user).exists()
|
||||
assert len(mail.outbox) == 1
|
||||
message = mail.outbox[0]
|
||||
assert message.to == [self.user.email]
|
||||
|
@ -1018,7 +1064,13 @@ class TestAPIKeyPage(TestCase):
|
|||
def test_create_new_credentials_not_confirmed_yet(self):
|
||||
assert not APIKey.objects.filter(user=self.user).exists()
|
||||
assert not APIKeyConfirmation.objects.filter(user=self.user).exists()
|
||||
response = self.client.post(self.url, data={'action': 'generate'})
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data={
|
||||
'action': APIKeyForm.ACTION_CHOICES.confirm,
|
||||
'g-recaptcha-response': 'test',
|
||||
},
|
||||
)
|
||||
self.assert3xx(response, self.url)
|
||||
|
||||
# Since there was no credentials are no confirmation yet, this should
|
||||
|
@ -1043,27 +1095,36 @@ class TestAPIKeyPage(TestCase):
|
|||
confirmation = APIKeyConfirmation.objects.create(
|
||||
user=self.user, token='doesnt matter', confirmed_once=False
|
||||
)
|
||||
response = self.client.post(self.url, data={'action': 'generate'})
|
||||
response = self.client.post(
|
||||
self.url, data={'action': APIKeyForm.ACTION_CHOICES.generate}
|
||||
)
|
||||
assert len(mail.outbox) == 0
|
||||
assert not APIKey.objects.filter(user=self.user).exists()
|
||||
confirmation.reload()
|
||||
assert not confirmation.confirmed_once # Unchanged
|
||||
self.assert3xx(response, self.url)
|
||||
form = response.context['form']
|
||||
assert not form.is_valid()
|
||||
assert '__all__' in form.errors
|
||||
|
||||
def test_create_new_credentials_confirmation_exists_token_is_wrong(self):
|
||||
confirmation = APIKeyConfirmation.objects.create(
|
||||
user=self.user, token='sometoken', confirmed_once=False
|
||||
)
|
||||
response = self.client.post(
|
||||
self.url, data={'action': 'generate', 'confirmation_token': 'wrong'}
|
||||
self.url,
|
||||
data={
|
||||
'action': APIKeyForm.ACTION_CHOICES.generate,
|
||||
'confirmation_token': 'wrong',
|
||||
},
|
||||
)
|
||||
# Nothing should have happened, the user will just be redirect to the
|
||||
# page.
|
||||
# Nothing should have happened, the user will just see the rendered form errors
|
||||
assert len(mail.outbox) == 0
|
||||
assert not APIKey.objects.filter(user=self.user).exists()
|
||||
confirmation.reload()
|
||||
assert not confirmation.confirmed_once
|
||||
self.assert3xx(response, self.url)
|
||||
form = response.context['form']
|
||||
assert form.is_valid() is False
|
||||
assert 'confirmation_token' in form.errors
|
||||
|
||||
def test_delete_and_recreate_credentials_has_been_confirmed_once(self):
|
||||
APIKeyConfirmation.objects.create(
|
||||
|
@ -1075,7 +1136,9 @@ class TestAPIKeyPage(TestCase):
|
|||
key='some-jwt-key',
|
||||
secret='some-jwt-secret',
|
||||
)
|
||||
response = self.client.post(self.url, data={'action': 'generate'})
|
||||
response = self.client.post(
|
||||
self.url, data={'action': APIKeyForm.ACTION_CHOICES.regenerate}
|
||||
)
|
||||
self.assert3xx(response, self.url)
|
||||
|
||||
old_key = APIKey.objects.get(pk=old_key.pk)
|
||||
|
@ -1092,31 +1155,34 @@ class TestAPIKeyPage(TestCase):
|
|||
key='some-jwt-key',
|
||||
secret='some-jwt-secret',
|
||||
)
|
||||
response = self.client.post(self.url, data={'action': 'generate'})
|
||||
response = self.client.post(
|
||||
self.url, data={'action': APIKeyForm.ACTION_CHOICES.regenerate}
|
||||
)
|
||||
form = response.context['form']
|
||||
assert not form.is_valid()
|
||||
assert '__all__' in form.errors
|
||||
|
||||
# We cannot regenerate without a confirmation
|
||||
assert old_key.reload().is_active
|
||||
|
||||
# Since there was no confirmation, the user can revoke the current key
|
||||
# effectively starting from the beginning with recaptcha and confirmation.
|
||||
response = self.client.post(
|
||||
self.url, data={'action': APIKeyForm.ACTION_CHOICES.revoke}
|
||||
)
|
||||
self.assert3xx(response, self.url)
|
||||
|
||||
old_key = APIKey.objects.get(pk=old_key.pk)
|
||||
assert old_key.is_active is None
|
||||
|
||||
# Since there was no confirmation, this should create a one, send an
|
||||
# email with the token, but not create credentials yet. (Would happen
|
||||
# for an user that had api keys from before we introduced confirmation
|
||||
# mechanism, but decided to regenerate).
|
||||
assert len(mail.outbox) == 2 # 2 because of key revocation email.
|
||||
assert len(mail.outbox) == 1
|
||||
assert 'revoked' in mail.outbox[0].body
|
||||
message = mail.outbox[1]
|
||||
message = mail.outbox[0]
|
||||
assert message.to == [self.user.email]
|
||||
assert not APIKey.objects.filter(user=self.user, is_active=True).exists()
|
||||
assert APIKeyConfirmation.objects.filter(user=self.user).exists()
|
||||
confirmation = APIKeyConfirmation.objects.filter(user=self.user).get()
|
||||
assert confirmation.token
|
||||
assert not confirmation.confirmed_once
|
||||
token = confirmation.token
|
||||
expected_url = (
|
||||
f'http://testserver/en-US/developers/addon/api/key/?token={token}'
|
||||
)
|
||||
assert message.subject == 'Confirmation for developer API keys'
|
||||
assert expected_url in message.body
|
||||
assert not APIKeyConfirmation.objects.filter(user=self.user).exists()
|
||||
|
||||
# Now the user is at the beginning and can generate new credentials
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
form = response.context['form']
|
||||
assert 'recaptcha' in form.fields
|
||||
|
||||
def test_delete_credentials(self):
|
||||
old_key = APIKey.objects.create(
|
||||
|
@ -1125,7 +1191,9 @@ class TestAPIKeyPage(TestCase):
|
|||
key='some-jwt-key',
|
||||
secret='some-jwt-secret',
|
||||
)
|
||||
response = self.client.post(self.url, data={'action': 'revoke'})
|
||||
response = self.client.post(
|
||||
self.url, data={'action': APIKeyForm.ACTION_CHOICES.revoke}
|
||||
)
|
||||
self.assert3xx(response, self.url)
|
||||
|
||||
old_key = APIKey.objects.get(pk=old_key.pk)
|
||||
|
@ -1147,6 +1215,28 @@ class TestAPIKeyPage(TestCase):
|
|||
)
|
||||
self.assert3xx(response, expected_location)
|
||||
|
||||
@override_switch('developer-submit-addon-captcha', active=False)
|
||||
def test_recaptcha_is_disabled(self):
|
||||
response = self.client.get(self.url)
|
||||
form = response.context['form']
|
||||
assert 'recaptcha' not in form.fields
|
||||
|
||||
def test_post_token_preferred_over_get_token(self):
|
||||
APIKeyConfirmation.objects.create(
|
||||
user=self.user, token='secrettoken', confirmed_once=False
|
||||
)
|
||||
response = self.client.post(
|
||||
f'{self.url}?token=secrettoken',
|
||||
data={
|
||||
'action': APIKeyForm.ACTION_CHOICES.generate,
|
||||
'confirmation_token': 'wrong',
|
||||
},
|
||||
)
|
||||
form = response.context['form']
|
||||
assert not form.is_valid()
|
||||
assert 'confirmation_token' in form.errors
|
||||
assert form.data.get('confirmation_token') == 'wrong'
|
||||
|
||||
|
||||
class TestUpload(UploadMixin, TestCase):
|
||||
fixtures = ['base/users']
|
||||
|
|
|
@ -54,7 +54,6 @@ from olympia.amo.utils import (
|
|||
send_mail,
|
||||
send_mail_jinja,
|
||||
)
|
||||
from olympia.api.models import APIKey, APIKeyConfirmation
|
||||
from olympia.devhub.decorators import (
|
||||
dev_required,
|
||||
no_admin_disabled,
|
||||
|
@ -2021,66 +2020,51 @@ def api_key(request):
|
|||
% (reverse('devhub.developer_agreement'), '?to=', quote(request.path))
|
||||
)
|
||||
|
||||
try:
|
||||
credentials = APIKey.get_jwt_key(user=request.user)
|
||||
except APIKey.DoesNotExist:
|
||||
credentials = None
|
||||
form = forms.APIKeyForm(
|
||||
request.POST if request.method == 'POST' else None,
|
||||
request=request,
|
||||
)
|
||||
|
||||
try:
|
||||
confirmation = APIKeyConfirmation.objects.get(user=request.user)
|
||||
except APIKeyConfirmation.DoesNotExist:
|
||||
confirmation = None
|
||||
if request.method == 'POST' and form.is_valid():
|
||||
result = form.save()
|
||||
|
||||
if request.method == 'POST':
|
||||
has_confirmed_or_is_confirming = confirmation and (
|
||||
confirmation.confirmed_once
|
||||
or confirmation.is_token_valid(request.POST.get('confirmation_token'))
|
||||
)
|
||||
|
||||
# Revoking credentials happens regardless of action, if there were
|
||||
# credentials in the first place.
|
||||
if credentials and request.POST.get('action') in ('revoke', 'generate'):
|
||||
credentials.update(is_active=None)
|
||||
log.info(f'revoking JWT key for user: {request.user.id}, {credentials}')
|
||||
send_key_revoked_email(request.user.email, credentials.key)
|
||||
msg = gettext('Your old credentials were revoked and are no longer valid.')
|
||||
messages.success(request, msg)
|
||||
|
||||
# If trying to generate with no confirmation instance, we don't
|
||||
# generate the keys immediately but instead send you an email to
|
||||
# confirm the generation of the key. This should only happen once per
|
||||
# user, unless the instance is deleted by admins to reset the process
|
||||
# for that user.
|
||||
if confirmation is None and request.POST.get('action') == 'generate':
|
||||
confirmation = APIKeyConfirmation.objects.create(
|
||||
user=request.user, token=APIKeyConfirmation.generate_token()
|
||||
if result.get('credentials_revoked'):
|
||||
log.info(
|
||||
f'revoking JWT key for user: {request.user.id}, {form.credentials}'
|
||||
)
|
||||
confirmation.send_confirmation_email()
|
||||
# If you have a confirmation instance, you need to either have it
|
||||
# confirmed once already or have the valid token proving you received
|
||||
# the email.
|
||||
elif (
|
||||
has_confirmed_or_is_confirming and request.POST.get('action') == 'generate'
|
||||
):
|
||||
confirmation.update(confirmed_once=True)
|
||||
new_credentials = APIKey.new_jwt_credentials(request.user)
|
||||
log.info(f'new JWT key created: {new_credentials}')
|
||||
send_key_change_email(request.user.email, new_credentials.key)
|
||||
else:
|
||||
# If we land here, either confirmation token is invalid, or action
|
||||
# is invalid, or state is outdated (like user trying to revoke but
|
||||
# there are already no credentials).
|
||||
# We can just pass and let the redirect happen.
|
||||
pass
|
||||
send_key_revoked_email(request.user.email, form.credentials.key)
|
||||
|
||||
# The user can revoke or regenerate.
|
||||
# If not regenerating, skip the rest of the logic.
|
||||
if not result.get('credentials_generated'):
|
||||
msg = gettext(
|
||||
'Your old credentials were revoked and are no longer valid.'
|
||||
)
|
||||
messages.success(request, msg)
|
||||
return redirect(reverse('devhub.api_key'))
|
||||
|
||||
if result.get('credentials_generated'):
|
||||
new_credentials = form.credentials
|
||||
log.info(f'new JWT key created: {new_credentials}')
|
||||
send_key_change_email(request.user.email, new_credentials)
|
||||
|
||||
if result.get('confirmation_created'):
|
||||
form.confirmation.send_confirmation_email()
|
||||
|
||||
# In any case, redirect after POST.
|
||||
return redirect(reverse('devhub.api_key'))
|
||||
|
||||
if form.credentials is not None:
|
||||
messages.error(
|
||||
request,
|
||||
_(
|
||||
'Keep your API keys secret and never share them with anyone, '
|
||||
'including Mozilla contributors.'
|
||||
),
|
||||
)
|
||||
|
||||
context_data = {
|
||||
'title': gettext('Manage API Keys'),
|
||||
'credentials': credentials,
|
||||
'confirmation': confirmation,
|
||||
'token': request.GET.get('token'), # For confirmation step.
|
||||
'form': form,
|
||||
}
|
||||
|
||||
return TemplateResponse(request, 'devhub/api/key.html', context=context_data)
|
||||
|
|
Загрузка…
Ссылка в новой задаче