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:
Kevin Meinhardt 2024-10-17 16:44:48 +02:00 коммит произвёл GitHub
Родитель 00565b80ab
Коммит f8e7fa2958
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
5 изменённых файлов: 539 добавлений и 187 удалений

Просмотреть файл

@ -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)