Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement APIKey as django form + recaptcha field #22759

Merged
merged 2 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>{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.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By making it a help_text on the recaptcha field that text is no longer displayed if the captcha isn't enabled... I guess this is fine because it's not a very useful text anyway ?

)
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,
}
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
Loading