Add pages for create/view/revoke API keys

Copying what they’ve done on GOV.UK Pay, we should let users:
- generate as many keys as they want
- only see the key at time of creation
- give keys a name
- revoke any key at any time (this should be a one way operation)

And based on discussions with @minglis and @servingUpAces, the keys should be
used in conjunction with some kind of service ID, which gets encrypted with the
key. In other words the secret itself never gets sent over the wire.

This commit adds the UI (but not the underlying API integration) for doing the
above.
This commit is contained in:
Chris Hill-Scott
2016-01-19 09:55:13 +00:00
committed by Rebecca Law
parent 5924500f3e
commit 9784a9936c
14 changed files with 356 additions and 63 deletions

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,7 @@ class ChangeMobileNumberForm(Form):
class ConfirmMobileNumberForm(Form):
sms_code = sms_code()
class CreateKeyForm(Form):
key_name = StringField(u'Description of key')

View File

@@ -1,9 +1,57 @@
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
@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=[
{'name': 'Test key 1', 'last_used': '12 January 2016, 10:01AM', 'id': 1},
{'name': 'Test key 2', 'last_used': '12 January 2016, 9:50AM', 'id': 1},
{'name': 'Test key 3', 'last_used': '12 January 2016, 9:49AM', 'id': 1},
{
'name': 'My first key', 'last_used': '25 December 2015, 09:49AM', 'id': 1,
'revoked': '4 January 2016, 6:00PM'
}
]
)
@main.route("/services/<int:service_id>/api-keys/create", methods=['GET', 'POST'])
@login_required
def create_api_key(service_id):
form = CreateKeyForm()
if form.validate_on_submit():
return redirect(url_for('.show_api_key', service_id=service_id))
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/show")
@login_required
def show_api_key(service_id):
return render_template('views/api-keys/show.html', service_id=service_id)
@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):
if request.method == 'GET':
return render_template('views/api-keys/revoke.html', service_id=service_id)
elif request.method == 'POST':
flash('Test key 1 was revoked')
return redirect(url_for('.api_keys', service_id=service_id))

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,55 @@
{% 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 for several languages which manage 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', 'Created at', hidden_field_heading('Action')]
) %}
{% call field() %}
{{ item.name }}
{% endcall %}
{% call field() %}
{{ item.last_used }}
{% endcall %}
{% if item.revoked %}
{% call field(align='right', status='default') %}
Revoked {{ item.revoked }}
{% endcall %}
{% else %}
{% call field(align='right', status='error') %}
<a href='{{ url_for('.revoke_api_key', service_id=123, 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>
Test key 1 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('d30512af92e1386d63b90e5973b49a10', 'CRM application') }}
</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

@@ -1,13 +1,87 @@
from flask import url_for
def test_should_show_api_keys_and_documentation_page(app_,
db_,
db_session,
active_user):
def test_should_show_documentation_page(app_,
db_,
db_session,
active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
response = client.get(url_for('main.documentation', service_id=123))
assert response.status_code == 200
def test_should_show_api_keys_page(app_,
db_,
db_session,
active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
response = client.get(url_for('main.api_keys', service_id=123))
assert response.status_code == 200
def test_should_show_name_api_key_page(app_,
db_,
db_session,
active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
response = client.get(url_for('main.create_api_key', service_id=123))
assert response.status_code == 200
def test_should_redirect_to_new_api_key(app_,
db_,
db_session,
active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
response = client.post(url_for('main.create_api_key', service_id=123))
assert response.status_code == 302
assert response.location == url_for('main.show_api_key', service_id=123, _external=True)
def test_should_show_new_api_key(app_,
db_,
db_session,
active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
response = client.get(url_for('main.show_api_key', service_id=123))
assert response.status_code == 200
def test_should_show_confirm_revoke_api_key(app_,
db_,
db_session,
active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_user)
response = client.get(url_for('main.revoke_api_key', service_id=123, key_id=321))
assert response.status_code == 200
def test_should_redirect_after_revoking_api_key(app_,
db_,
db_session,
active_user):
with app_.test_request_context():
with app_.test_client() as client:
client.login(active_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)