Merge pull request #96 from alphagov/api-keys-flow

Add pages for create/view/revoke API keys
This commit is contained in:
Adam Shimali
2016-01-21 17:27:08 +00:00
19 changed files with 506 additions and 62 deletions

View File

@@ -1,6 +1,7 @@
import os
import re
import dateutil
from flask import Flask, session, Markup, escape, render_template
from flask._compat import string_types
from flask.ext.sqlalchemy import SQLAlchemy
@@ -8,6 +9,7 @@ from flask_login import LoginManager
from flask_wtf import CsrfProtect
from werkzeug.exceptions import abort
from app.notify_client.api_client import NotificationsAdminAPIClient
from app.notify_client.api_key_api_client import ApiKeyApiClient
from app.notify_client.user_api_client import UserApiClient
from app.its_dangerous_session import ItsdangerousSessionInterface
import app.proxy_fix
@@ -20,6 +22,7 @@ csrf = CsrfProtect()
notifications_api_client = NotificationsAdminAPIClient()
user_api_client = UserApiClient()
api_key_api_client = ApiKeyApiClient()
def create_app(config_name, config_overrides=None):
@@ -34,6 +37,7 @@ def create_app(config_name, config_overrides=None):
notifications_api_client.init_app(application)
user_api_client.init_app(application)
api_key_api_client.init_app(application)
login_manager.init_app(application)
login_manager.login_view = 'main.sign_in'
@@ -51,6 +55,7 @@ def create_app(config_name, config_overrides=None):
application.add_template_filter(placeholders)
application.add_template_filter(replace_placeholders)
application.add_template_filter(nl2br)
application.add_template_filter(format_datetime)
application.after_request(useful_headers_after_request)
register_errorhandlers(application)
@@ -131,6 +136,12 @@ def replace_placeholders(template, values):
))
def format_datetime(date):
date = dateutil.parser.parse(date)
native = date.replace(tzinfo=None)
return native.strftime('%A %d %B %Y at %H:%M')
# https://www.owasp.org/index.php/List_of_useful_HTTP_headers
def useful_headers_after_request(response):
response.headers.add('X-Frame-Options', 'deny')

View File

@@ -1,15 +1,11 @@
(function(Modules) {
"use strict";
if (!document.queryCommandSupported('copy')) return;
Modules.ApiKey = function() {
const states = {
'initial': `
<input type='button' class='api-key-button-show' value='Show API key' />
`,
'keyVisibleBasic': key => `
<span class="api-key-key">${key}</span>
`,
'keyVisible': key => `
<span class="api-key-key">${key}</span>
<input type='button' class='api-key-button-copy' value='Copy API key to clipboard' />
@@ -33,23 +29,22 @@
this.start = function(component) {
const $component = $(component).html(states.initial).attr('aria-live', 'polite'),
const $component = $(component),
key = $component.data('key');
$component
.on(
'click', '.api-key-button-show', () =>
$component.html(
document.queryCommandSupported('copy') ?
states.keyVisible(key) : states.keyVisibleBasic(key)
)
)
.html(states.keyVisible(key))
.attr('aria-live', 'polite')
.on(
'click', '.api-key-button-copy', () =>
this.copyKey(
$('.api-key-key', component)[0], () =>
$component.html(states.keyCopied)
)
)
.on(
'click', '.api-key-button-show', () =>
$component.html(states.keyVisible(key))
);
};

View File

@@ -1,5 +1,10 @@
.api-key {
&-name {
@include bold-19;
margin-bottom: 5px;
}
&-key {
font-family: monospace;
display: block;

View File

@@ -1,3 +1,7 @@
.table {
margin-bottom: $gutter;
}
.table-heading {
text-align: left;
}
@@ -16,8 +20,15 @@
}
&-error {
color: $error-colour;
font-weight: bold;
a:link,
a:visited {
color: $error-colour;
}
}
}

View File

@@ -235,3 +235,17 @@ class ChangeMobileNumberForm(Form):
class ConfirmMobileNumberForm(Form):
sms_code = sms_code()
class CreateKeyForm(Form):
def __init__(self, existing_key_names=[], *args, **kwargs):
self.existing_key_names = [x.lower() for x in existing_key_names]
super(CreateKeyForm, self).__init__(*args, **kwargs)
key_name = StringField(u'Description of key', validators=[
DataRequired(message='You need to give the key a name')
])
def validate_key_name(self, key_name):
if key_name.data.lower() in self.existing_key_names:
raise ValidationError('A key with this name already exists')

View File

@@ -1,9 +1,55 @@
from flask import render_template
from flask import request, render_template, redirect, url_for, flash
from flask_login import login_required
from app.main import main
from app.main.forms import CreateKeyForm
from app import api_key_api_client
@main.route("/services/<int:service_id>/documentation")
@login_required
def documentation(service_id):
return render_template('views/documentation.html', service_id=service_id)
@main.route("/services/<int:service_id>/api-keys")
@login_required
def api_keys(service_id):
return render_template('views/api-keys.html', service_id=service_id)
return render_template(
'views/api-keys.html',
service_id=service_id,
keys=api_key_api_client.get_api_keys(service_id=service_id)['apiKeys']
)
@main.route("/services/<int:service_id>/api-keys/create", methods=['GET', 'POST'])
@login_required
def create_api_key(service_id):
key_names = [
key['name'] for key in api_key_api_client.get_api_keys(service_id=service_id)['apiKeys']
]
form = CreateKeyForm(key_names)
if form.validate_on_submit():
secret = api_key_api_client.create_api_key(service_id=service_id, key_name=form.key_name.data)
return render_template('views/api-keys/show.html', service_id=service_id, secret=secret,
key_name=form.key_name.data)
return render_template(
'views/api-keys/create.html',
service_id=service_id,
key_name=form.key_name
)
@main.route("/services/<int:service_id>/api-keys/revoke/<int:key_id>", methods=['GET', 'POST'])
@login_required
def revoke_api_key(service_id, key_id):
key_name = api_key_api_client.get_api_keys(service_id=service_id, key_id=key_id)['apiKeys'][0]['name']
if request.method == 'GET':
return render_template(
'views/api-keys/revoke.html',
service_id=service_id,
key_name=key_name
)
elif request.method == 'POST':
api_key_api_client.revoke_api_key(service_id=service_id, key_id=key_id)
flash('{} was revoked'.format(key_name))
return redirect(url_for('.api_keys', service_id=service_id))

View File

@@ -0,0 +1,27 @@
from client.base import BaseAPIClient
class ApiKeyApiClient(BaseAPIClient):
def __init__(self, base_url=None, client_id=None, secret=None):
super(self.__class__, self).__init__(base_url=base_url or 'base_url',
client_id=client_id or 'client_id',
secret=secret or 'secret')
def init_app(self, app):
self.base_url = app.config['API_HOST_NAME']
self.client_id = app.config['ADMIN_CLIENT_USER_NAME']
self.secret = app.config['ADMIN_CLIENT_SECRET']
def get_api_keys(self, service_id, key_id=None, *params):
if key_id:
return self.get(url='/service/{}/api-keys/{}'.format(service_id, key_id))
else:
return self.get(url='/service/{}/api-keys'.format(service_id))
def create_api_key(self, service_id, key_name, *params):
data = {"name": key_name}
key = self.post(url='/service/{}/api-key'.format(service_id), data=data)
return key['data']
def revoke_api_key(self, service_id, key_id, *params):
return self.post(url='/service/{0}/api-key/revoke/{1}'.format(service_id, key_id), data=None)

View File

@@ -1,4 +1,7 @@
{% macro api_key(key) %}
{% macro api_key(key, name) %}
<h2 class="api-key-name">
{{ name }}
</h2>
<div data-module="api-key" data-key="{{ key }}">
<span class="api-key-key">{{ key }}</span>
</div>

View File

@@ -55,3 +55,7 @@
{% macro right_aligned_field_heading(text) %}
<span class="table-field-heading-right-aligned">{{ text }}</span>
{%- endmacro %}
{% macro hidden_field_heading(text) %}
<span class="visuallyhidden">{{ text }}</span>
{%- endmacro %}

View File

@@ -9,7 +9,8 @@
<li><a href="{{ url_for('.manage_service_templates', service_id=service_id) }}">Templates</a></li>
</ul>
<ul>
<li><a href="{{ url_for('.api_keys', service_id=service_id) }}">API keys and documentation</a></li>
<li><a href="{{ url_for('.api_keys', service_id=123) }}">API keys</a></li>
<li><a href="{{ url_for('.documentation', service_id=123) }}">Developer documentation</a></li>
</ul>
<ul>
<li><a href="{{ url_for('.manage_users', service_id=service_id) }}">Manage users</a></li>

View File

@@ -1,6 +1,5 @@
{% extends "withnav_template.html" %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/api-key.html" import api_key %}
{% from "components/table.html" import list_table, field, hidden_field_heading %}
{% block page_title %}
GOV.UK Notify | API keys and documentation
@@ -8,49 +7,51 @@
{% block maincolumn_content %}
<div class="grid-row">
<div class="column-two-thirds">
<h1 class="heading-xlarge">
API keys
</h1>
<h1 class="heading-xlarge">
API keys and documentation
</h1>
<p>
To connect to the API you will need to send your service ID, encrypted with
an API key. The API key stays secret.
</p>
<h2 class="heading-medium">
How to integrate GOV.UK Notify into your service
</h2>
<p>
There are client libraries available which can do this for you. See
<a href="{{ url_for(".documentation", service_id=service_id) }}">the
developer documentation</a> for more information.
</p>
<p>
blah blah blah this is where we tell you how the API works
</p>
<h2 class="api-key-name">
Service ID
</h2>
<p class="api-key-key">
{{ service_id }}
</p>
<h2 class="heading-medium">Repositories</h2>
{% call(item) list_table(
keys,
empty_message="You havent created any API keys yet",
caption="API keys",
caption_visible=False,
field_headings=['Key name', hidden_field_heading('Action')]
) %}
{% call field() %}
{{ item.name }}
{% endcall %}
{% if item.expiry_date %}
{% call field(align='right', status='default') %}
Revoked {{ item.expiry_date|format_datetime }}
{% endcall %}
{% else %}
{% call field(align='right', status='error') %}
<a href='{{ url_for('.revoke_api_key', service_id=service_id, key_id=item.id) }}'>Revoke</a>
{% endcall %}
{% endif %}
{% endcall %}
<p>
<a href="https://github.com/alphagov/notifications-api">GOV.UK Notify API</a>
</p>
<p>
<a href="https://github.com/alphagov/notify-api-client">GOV.UK Notify Python client</a>
</p>
<h2 class="heading-medium">API key for [service name]</h2>
{{ api_key('d30512af92e1386d63b90e5973b49a10') }}
<h2 class="heading-medium">API endpoint</h2>
<p>
https://www.notify.works/api/endpoint
</p>
</div>
</div>
{{ page_footer(
back_link=url_for('.service_dashboard', service_id=service_id),
back_link_text='Back to dashboard'
) }}
<p>
<a href="{{ url_for('.create_api_key', service_id=service_id) }}">Create a new API key</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "withnav_template.html" %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/textbox.html" import textbox %}
{% block page_title %}
GOV.UK Notify | API keys and documentation
{% endblock %}
{% block maincolumn_content %}
<h1 class="heading-xlarge">
Add a new API key
</h1>
<form method="post">
{{ textbox(key_name, hint='eg CRM application') }}
{{ page_footer(
'Continue',
back_link=url_for('.api_keys', service_id=service_id),
back_link_text='Back to API keys'
) }}
</form>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "withnav_template.html" %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/api-key.html" import api_key %}
{% block page_title %}
GOV.UK Notify | API keys and documentation
{% endblock %}
{% block maincolumn_content %}
<div class="grid-row">
<div class="column-two-thirds">
<h1 class="heading-xlarge">
Revoke API key
</h1>
<p>
{{ key_name }} will no longer let you connect to GOV.UK Notify.
</p>
<p>
You cant undo this.
</p>
</div>
</div>
<form method="post">
{{ page_footer(
'Revoke this API key',
back_link=url_for('.api_keys', service_id=service_id),
back_link_text='Back to API keys',
destructive=True
) }}
</form>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "withnav_template.html" %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/api-key.html" import api_key %}
{% block page_title %}
GOV.UK Notify | API keys and documentation
{% endblock %}
{% block maincolumn_content %}
<div class="grid-row">
<div class="column-two-thirds">
<h1 class="heading-xlarge">
New API key
</h1>
<p>
Copy your key to somewhere safe. You wont be able to see it again
once you leave this page.
</p>
{{ api_key(secret, key_name) }}
</div>
</div>
{{ page_footer(
back_link=url_for('.api_keys', service_id=service_id),
back_link_text='Back to API keys'
) }}
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends "withnav_template.html" %}
{% from "components/page-footer.html" import page_footer %}
{% from "components/api-key.html" import api_key %}
{% block page_title %}
GOV.UK Notify | API keys and documentation
{% endblock %}
{% block maincolumn_content %}
<div class="grid-row">
<div class="column-two-thirds">
<h1 class="heading-xlarge">
Developer documentation
</h1>
<h2 class="heading-medium">
How to integrate GOV.UK Notify into your service
</h2>
<p>
blah blah blah this is where we tell you how the API works
</p>
<h2 class="heading-medium">Repositories</h2>
<p>
<a href="https://github.com/alphagov/notifications-api">GOV.UK Notify API</a>
</p>
<p>
<a href="https://github.com/alphagov/notify-api-client">GOV.UK Notify Python client</a>
</p>
<h2 class="heading-medium">API endpoint</h2>
<p>
https://www.notify.works/api/endpoint
</p>
<p>
<a href="{{ url_for('.api_keys', service_id=service_id) }}">API keys for your service</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -39,6 +39,12 @@ def template_json(id_, name, type_, content, service_id):
}
def api_key_json(id_, name, expiry_date=None):
return {'id': id_,
'name': name,
'expiry_date': expiry_date
}
TEST_USER_EMAIL = 'test@user.gov.uk'

View File

@@ -0,0 +1,16 @@
from werkzeug.datastructures import MultiDict
from app.main.forms import CreateKeyForm
def test_return_validation_error_when_key_name_exists(app_,
db_,
db_session):
def _get_names():
return ['some key', 'another key']
with app_.test_request_context():
form = CreateKeyForm(_get_names(),
formdata=MultiDict([('key_name', 'Some key')]))
form.validate()
assert {'key_name': ['A key with this name already exists']} == form.errors

View File

@@ -1,3 +1,4 @@
from datetime import date
from flask import url_for
@@ -7,9 +8,116 @@ def test_should_show_api_keys_and_documentation_page(app_,
mock_api_user,
mock_user_loader,
mock_user_dao_get_by_email):
with app_.test_request_context():
with app_.test_client() as client:
client.login(mock_api_user)
response = client.get(url_for('main.documentation', service_id=123))
assert response.status_code == 200
def test_should_show_empty_api_keys_page(app_,
db_,
db_session,
mock_api_user,
mock_user_loader,
mock_user_dao_get_by_email,
mock_get_no_api_keys):
with app_.test_request_context():
with app_.test_client() as client:
client.login(mock_api_user)
response = client.get(url_for('main.api_keys', service_id=123))
assert response.status_code == 200
assert 'You havent created any API keys yet' in response.get_data(as_text=True)
assert 'Create a new API key' in response.get_data(as_text=True)
mock_get_no_api_keys.assert_called_once_with(service_id=123)
def test_should_show_api_keys_page(app_,
db_,
db_session,
mock_api_user,
mock_user_loader,
mock_user_dao_get_by_email,
mock_get_api_keys):
with app_.test_request_context():
with app_.test_client() as client:
client.login(mock_api_user)
response = client.get(url_for('main.api_keys', service_id=123))
assert response.status_code == 200
assert 'some key name' in response.get_data(as_text=True)
assert 'another key name' in response.get_data(as_text=True)
assert 'Revoked Thursday 01 January 1970 at 00:00' in response.get_data(as_text=True)
mock_get_api_keys.assert_called_once_with(service_id=123)
def test_should_show_name_api_key_page(app_,
db_,
db_session,
mock_api_user,
mock_user_loader,
mock_user_dao_get_by_email,
mock_get_api_keys):
with app_.test_request_context():
with app_.test_client() as client:
client.login(mock_api_user)
response = client.get(url_for('main.create_api_key', service_id=123))
assert response.status_code == 200
def test_should_render_show_api_key(app_,
db_,
db_session,
mock_api_user,
mock_user_loader,
mock_user_dao_get_by_email,
mock_create_api_key,
mock_get_api_keys):
with app_.test_request_context():
with app_.test_client() as client:
client.login(mock_api_user)
response = client.post(url_for('main.create_api_key', service_id=123),
data={'key_name': 'some default key name'})
assert response.status_code == 200
assert 'some default key name' in response.get_data(as_text=True)
mock_create_api_key.assert_called_once_with(service_id=123, key_name='some default key name')
def test_should_show_confirm_revoke_api_key(app_,
db_,
db_session,
mock_api_user,
mock_user_loader,
mock_user_dao_get_by_email,
mock_get_api_keys):
with app_.test_request_context():
with app_.test_client() as client:
client.login(mock_api_user)
response = client.get(url_for('main.revoke_api_key', service_id=123, key_id=321))
assert response.status_code == 200
assert 'some key name' in response.get_data(as_text=True)
mock_get_api_keys.assert_called_once_with(service_id=123, key_id=321)
def test_should_redirect_after_revoking_api_key(app_,
db_,
db_session,
mock_api_user,
mock_user_loader,
mock_user_dao_get_by_email,
mock_revoke_api_key,
mock_get_api_keys):
with app_.test_request_context():
with app_.test_client() as client:
client.login(mock_api_user)
response = client.post(url_for('main.revoke_api_key', service_id=123, key_id=321))
assert response.status_code == 302
assert response.location == url_for('.api_keys', service_id=123, _external=True)
mock_revoke_api_key.assert_called_once_with(service_id=123, key_id=321)
mock_get_api_keys.assert_called_once_with(service_id=123, key_id=321)

View File

@@ -1,14 +1,17 @@
import os
from datetime import date
import pytest
from alembic.command import upgrade
from alembic.config import Config
from flask.ext.migrate import Migrate, MigrateCommand
from flask.ext.script import Manager
from sqlalchemy.schema import MetaData
from . import (
create_test_user, create_another_test_user, service_json, TestClient,
get_test_user, template_json)
from app import create_app, db
from . import (
create_test_user, service_json, TestClient,
get_test_user, template_json, api_key_json)
@pytest.fixture(scope='session')
@@ -89,6 +92,7 @@ def mock_get_service(mocker, mock_api_user):
service_id, "Test Service", [mock_api_user.id], limit=1000,
active=False, restricted=True)
return {'data': service, 'token': 1}
return mocker.patch('app.notifications_api_client.get_service', side_effect=_create)
@@ -99,6 +103,7 @@ def mock_create_service(mocker):
101, service_name, [user_id], limit=limit,
active=active, restricted=restricted)
return {'data': service}
mock_class = mocker.patch(
'app.notifications_api_client.create_service', side_effect=_create)
return mock_class
@@ -116,6 +121,7 @@ def mock_update_service(mocker):
service_id, service_name, users, limit=limit,
active=active, restricted=restricted)
return {'data': service}
mock_class = mocker.patch(
'app.notifications_api_client.update_service', side_effect=_update)
return mock_class
@@ -129,6 +135,7 @@ def mock_get_services(mocker, mock_api_user):
service_two = service_json(
2, "service_two", [mock_api_user.id], 1000, True, False)
return {'data': [service_one, service_two]}
mock_class = mocker.patch(
'app.notifications_api_client.get_services', side_effect=_create)
return mock_class
@@ -138,6 +145,7 @@ def mock_get_services(mocker, mock_api_user):
def mock_delete_service(mocker, mock_get_service):
def _delete(service_id):
return mock_get_service.side_effect(service_id)
mock_class = mocker.patch(
'app.notifications_api_client.delete_service', side_effect=_delete)
return mock_class
@@ -149,6 +157,7 @@ def mock_get_service_template(mocker):
template = template_json(
template_id, "Template Name", "sms", "template content", service_id)
return {'data': template}
return mocker.patch(
'app.notifications_api_client.get_service_template',
side_effect=_create)
@@ -160,6 +169,7 @@ def mock_create_service_template(mocker):
template = template_json(
101, name, type_, content, service)
return {'data': template}
mock_class = mocker.patch(
'app.notifications_api_client.create_service_template',
side_effect=_create)
@@ -172,6 +182,7 @@ def mock_update_service_template(mocker):
template = template_json(
id_, name, type_, content, service)
return {'data': template}
mock_class = mocker.patch(
'app.notifications_api_client.update_service_template',
side_effect=_update)
@@ -186,6 +197,7 @@ def mock_get_service_templates(mocker):
template_two = template_json(
2, "template_two", "sms", "template two content", service_id)
return {'data': [template_one, template_two]}
mock_class = mocker.patch(
'app.notifications_api_client.get_service_templates',
side_effect=_create)
@@ -199,6 +211,7 @@ def mock_delete_service_template(mocker):
template_id, "Template to delete",
"sms", "content to be deleted", service_id)
return {'data': template}
return mocker.patch(
'app.notifications_api_client.delete_service_template', side_effect=_delete)
@@ -314,3 +327,43 @@ def mock_user_dao_get_new_password(mocker, mock_api_user):
mock_class = mocker.patch('app.main.dao.users_dao.get_user_by_email')
mock_class.return_value = mock_api_user
return mock_class
@pytest.fixture(scope='function')
def mock_create_api_key(mocker):
def _create(service_id, key_name):
import uuid
return {'data': str(uuid.uuid4())}
mock_class = mocker.patch('app.api_key_api_client.create_api_key', side_effect=_create)
return mock_class
@pytest.fixture(scope='function')
def mock_revoke_api_key(mocker):
def _revoke(service_id, key_id):
return {}
mock_class = mocker.patch('app.api_key_api_client.revoke_api_key', side_effect=_revoke)
return mock_class
@pytest.fixture(scope='function')
def mock_get_api_keys(mocker):
def _get_keys(service_id, key_id=None):
keys = {'apiKeys': [api_key_json(1, 'some key name'),
api_key_json(2, 'another key name', expiry_date=str(date.fromtimestamp(0)))]}
return keys
mock_class = mocker.patch('app.api_key_api_client.get_api_keys', side_effect=_get_keys)
return mock_class
@pytest.fixture(scope='function')
def mock_get_no_api_keys(mocker):
def _get_keys(service_id):
keys = {'apiKeys': []}
return keys
mock_class = mocker.patch('app.api_key_api_client.get_api_keys', side_effect=_get_keys)
return mock_class