Skip to content

Commit

Permalink
API Key form with recaptcha and more susinct rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
KevinMind committed Oct 16, 2024
1 parent cd974ab commit 830670b
Show file tree
Hide file tree
Showing 5 changed files with 538 additions and 185 deletions.
154 changes: 154 additions & 0 deletions src/olympia/devhub/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import tarfile
import zipfile
from functools import cached_property
from urllib.parse import urlsplit

from django import forms
Expand All @@ -17,6 +18,7 @@

import waffle
from django_statsd.clients import statsd
from extended_choices import Choices

from olympia import amo
from olympia.access import acl
Expand All @@ -42,6 +44,7 @@
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
Expand Down Expand Up @@ -1424,3 +1427,154 @@ def clean(self):
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>{user}</strong>.'
).format(user=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 new 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 with pending credentials is trying to complete 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,
}
98 changes: 29 additions & 69 deletions src/olympia/devhub/templates/devhub/api/key.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,84 +9,44 @@ <h1>{{ title }}</h1>

<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>
Expand Down
Loading

0 comments on commit 830670b

Please sign in to comment.