All tests passing and merged with master.

This commit is contained in:
Nicholas Staples
2016-01-27 16:30:33 +00:00
51 changed files with 763 additions and 892 deletions

View File

@@ -1,5 +1,6 @@
%banner,
.banner {
.banner,
.banner-default {
@include core-19;
background: $turquoise;

View File

@@ -56,6 +56,7 @@
.table-empty-message {
@include core-16;
color: $secondary-text-colour;
border-top: 1px solid $border-colour;
border-bottom: 1px solid $border-colour;
padding: 5px 0 8px 0;
padding: 0.75em 0 0.5625em 0;
}

View File

@@ -26,8 +26,11 @@ def get_service_by_id(id_):
return notifications_api_client.get_service(id_)
def get_services():
return notifications_api_client.get_services()
def get_services(user_id=None):
if user_id:
return notifications_api_client.get_services({'user_id': str(user_id)})
else:
return notifications_api_client.get_services()
def unrestrict_service(service_id):
@@ -55,8 +58,8 @@ def activate_service(service_id):
# TODO Fix when functionality is added to the api.
def find_service_by_service_name(service_name):
resp = notifications_api_client.get_services()
def find_service_by_service_name(service_name, user_id=None):
resp = notifications_api_client.get_services(user_id)
retval = None
for srv_json in resp['data']:
if srv_json['name'] == service_name:
@@ -69,8 +72,8 @@ def delete_service(id_):
return notifications_api_client.delete_service(id_)
def find_all_service_names():
resp = notifications_api_client.get_services()
def find_all_service_names(user_id=None):
resp = notifications_api_client.get_services(user_id)
return [x['name'] for x in resp['data']]
@@ -90,4 +93,4 @@ class ServicesBrowsableItem(BrowsableItem):
@property
def hint(self):
return "Some service hint here"
return None

View File

@@ -38,6 +38,7 @@ def update_user(user):
def increment_failed_login_count(id):
user = get_user_by_id(id)
user.failed_login_count += 1
return user_api_client.update_user(user)
def activate_user(user):
@@ -45,38 +46,19 @@ def activate_user(user):
return user_api_client.update_user(user)
def update_email_address(id, email_address):
user = get_user_by_id(id)
user.email_address = email_address
# TODO update user
def is_email_unique(email_address):
if user_api_client.get_user_by_email(email_address):
return False
return True
def update_mobile_number(id, mobile_number):
user = get_user_by_id(id)
user.mobile_number = mobile_number
# TODO update user
def update_password(user, password):
user.password = hashpw(password)
user.password_changed_at = datetime.now()
user.state = 'active'
# TODO update user
def request_password_reset(email):
user = get_user_by_email(email)
user.state = 'request_password_reset'
# TODO update user
def send_verify_code(user_id, code_type):
def send_verify_code(user_id, code_type, to=None):
return user_api_client.send_verify_code(user_id, code_type)

View File

@@ -105,8 +105,8 @@ class LoginForm(Form):
class RegisterUserForm(Form):
def __init__(self, existing_email_addresses, *args, **kwargs):
self.existing_emails = existing_email_addresses
def __init__(self, unique_email_func, *args, **kwargs):
self.unique_email_func = unique_email_func
super(RegisterUserForm, self).__init__(*args, **kwargs)
name = StringField('Full name',
@@ -117,7 +117,7 @@ class RegisterUserForm(Form):
def validate_email_address(self, field):
# Validate email address is unique.
if self.existing_emails(field.data):
if not self.unique_email_func(field.data):
raise ValidationError('Email address already exists')

View File

@@ -1,12 +0,0 @@
templates = [
{
'type': 'sms',
'name': 'Confirmation with details Jan 2016',
'body': '((name)), weve received your ((thing)). Well contact you again within 1 week.'
},
{
'type': 'sms',
'name': 'Confirmation Jan 2016',
'body': 'Weve received your payment. Well contact you again within 1 week.'
}
]

View File

@@ -1,5 +1,5 @@
from flask import request, render_template, jsonify, redirect, session, url_for, abort
from flask_login import login_required
from flask import render_template, redirect, session, url_for
from flask_login import login_required, current_user
from app.main import main
from app.main.dao import services_dao, users_dao
from app.main.forms import AddServiceForm
@@ -9,7 +9,7 @@ from app.main.forms import AddServiceForm
@login_required
def add_service():
form = AddServiceForm(services_dao.find_all_service_names)
services = services_dao.get_services()
services = services_dao.get_services(current_user.id)
if len(services) > 0:
heading = 'Set up notifications for your service'
else:

View File

@@ -51,5 +51,5 @@ def revoke_api_key(service_id, key_id):
)
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))
flash('{} was revoked'.format(key_name), 'default')
return redirect(url_for('.api_keys', service_id=service_id))

View File

@@ -1,5 +1,5 @@
from flask import (render_template, redirect, url_for)
from flask_login import login_required
from flask_login import login_required, current_user
from app.main.dao import services_dao
from app.main import main
@@ -7,7 +7,7 @@ from app.main import main
@main.route("/services")
@login_required
def choose_service():
services = services_dao.get_services()
services = services_dao.get_services(current_user.id)
# If there is only one service redirect
# to the service dashboard.
if len(services['data']) == 1:

View File

@@ -1,6 +1,8 @@
from flask import (
render_template, redirect, session, url_for)
from flask_login import current_user
from app.main import main
from app.main.dao import users_dao
from app.main.forms import EmailNotReceivedForm, TextNotReceivedForm
@@ -12,8 +14,9 @@ def check_and_resend_email_code():
user = users_dao.get_user_by_email(session['user_details']['email'])
form = EmailNotReceivedForm(email_address=user.email_address)
if form.validate_on_submit():
users_dao.update_email_address(id=user.id, email_address=form.email_address.data)
users_dao.send_verify_code(user.id, 'email')
users_dao.send_verify_code(user.id, 'email', to=form.email_address.data)
user.email_address = form.email_address.data
users_dao.update_user(user)
return redirect(url_for('.verify'))
return render_template('views/email-not-received.html', form=form)
@@ -24,8 +27,9 @@ def check_and_resend_text_code():
user = users_dao.get_user_by_email(session['user_details']['email'])
form = TextNotReceivedForm(mobile_number=user.mobile_number)
if form.validate_on_submit():
users_dao.update_mobile_number(id=user.id, mobile_number=form.mobile_number.data)
users_dao.send_verify_code(user.id, 'sms')
users_dao.send_verify_code(user.id, 'sms', to=form.mobile_number.data)
user.mobile_number = form.mobile_number.data
users_dao.update_user(user)
return redirect(url_for('.verify'))
return render_template('views/text-not-received.html', form=form)

View File

@@ -1,10 +1,14 @@
from flask import render_template
from flask import render_template, url_for, redirect
from app.main import main
from flask_login import login_required
from flask.ext.login import current_user
@main.route('/')
def index():
if current_user and current_user.is_authenticated():
return redirect(url_for('main.choose_service'))
return render_template('views/signedout.html')

View File

@@ -21,8 +21,11 @@ def new_password(token):
form = NewPasswordForm()
if form.validate_on_submit():
users_dao.update_password(user, form.new_password.data)
users_dao.send_verify_code(user.id, 'sms')
session['user_details'] = {
'id': user.id,
'email': user.email_address,
'password': form.new_password.data}
return redirect(url_for('main.two_factor'))
else:
return render_template('views/new-password.html', token=token, form=form, user=user)

View File

@@ -8,6 +8,8 @@ from flask import (
url_for
)
from flask.ext.login import current_user
from client.errors import HTTPError
from app.main import main
@@ -19,7 +21,10 @@ from app import user_api_client
@main.route('/register', methods=['GET', 'POST'])
def register():
form = RegisterUserForm(users_dao.get_user_by_email)
if current_user and current_user.is_authenticated():
return redirect(url_for('main.choose_service'))
form = RegisterUserForm(users_dao.is_email_unique)
if form.validate_on_submit():
try:

View File

@@ -6,6 +6,8 @@ from flask import (
abort
)
from flask.ext.login import current_user
from app.main import main
from app.main.dao import users_dao
from app.main.forms import LoginForm
@@ -13,18 +15,31 @@ from app.main.forms import LoginForm
@main.route('/sign-in', methods=(['GET', 'POST']))
def sign_in():
if current_user and current_user.is_authenticated():
return redirect(url_for('main.choose_service'))
form = LoginForm()
if form.validate_on_submit():
user = users_dao.get_user_by_email(form.email_address.data)
user = _get_and_verify_user(form.email_address.data, form.password.data)
if user:
if not user.is_locked() and user.is_active() and users_dao.verify_password(user, form.password.data):
users_dao.send_verify_code(user.id, 'sms')
session['user_details'] = {"email": user.email_address, "id": user.id}
return redirect(url_for('.two_factor'))
else:
# TODO re wire this increment to api
users_dao.increment_failed_login_count(user.id)
# Vague error message for login
form.password.errors.append('Username or password is incorrect')
users_dao.send_verify_code(user.id, 'sms')
session['user_details'] = {"email": user.email_address, "id": user.id}
return redirect(url_for('.two_factor'))
else:
# Vague error message for login in case of user not known, locked, inactive or password not verified
form.password.errors.append('Username or password is incorrect')
return render_template('views/signin.html', form=form)
def _get_and_verify_user(email_address, password):
user = users_dao.get_user_by_email(email_address)
if not user:
return None
elif user.is_locked():
return None
elif not user.is_active():
return None
elif not users_dao.verify_password(user, password):
return None
else:
return user

View File

@@ -24,12 +24,7 @@ from app.main.uploader import (
s3upload,
s3download
)
from ._templates import templates
sms_templates = [
template for template in templates if template['type'] == 'sms'
]
from app.main.dao import templates_dao
@main.route("/services/<int:service_id>/sms/send", methods=['GET', 'POST'])
@@ -51,8 +46,16 @@ def send_sms(service_id):
flash(str(e))
return redirect(url_for('.send_sms', service_id=service_id))
try:
templates = templates_dao.get_service_templates(service_id)['data']
except HTTPError as e:
if e.status_code == 404:
abort(404)
else:
raise e
return render_template('views/send-sms.html',
message_templates=sms_templates,
templates=templates,
form=form,
service_id=service_id)
@@ -69,7 +72,9 @@ def check_sms(service_id, upload_id):
'views/check-sms.html',
upload_result=upload_result,
filename='someupload_file_name.csv',
message_template=sms_templates[0]['body'],
message_template='''
((name)), weve received your ((thing)). Well contact you again within 1 week.
''',
service_id=service_id
)
elif request.method == 'POST':

View File

@@ -20,8 +20,12 @@ def two_factor():
form = TwoFactorForm(_check_code)
if form.validate_on_submit():
del session['user_details']
user = users_dao.get_user_by_id(user_id)
# Check if coming from new password page
if 'password' in session['user_details']:
user.set_password(session['user_details']['password'])
users_dao.update_user(user)
del session['user_details']
login_user(user)
return redirect(url_for('.choose_service'))

View File

@@ -70,8 +70,10 @@ class UserApiClient(BaseAPIClient):
return user[0]
return None
def send_verify_code(self, user_id, code_type):
def send_verify_code(self, user_id, code_type, to=None):
data = {'code_type': code_type}
if to:
data['to'] = to
endpoint = '/user/{}/code'.format(user_id)
resp = self.post(endpoint, data=data)
@@ -92,41 +94,14 @@ class UserApiClient(BaseAPIClient):
class User(object):
def __init__(self, fields, max_failed_login_count=3):
self.fields = fields
self.max_failed_login_count = max_failed_login_count
self._id = fields.get('id')
self._name = fields.get('name')
self._email_address = fields.get('email_address')
self._mobile_number = fields.get('mobile_number')
self._password_changed_at = fields.get('password_changed_at')
self._failed_login_count = 0
@property
def id(self):
return self.fields.get('id')
@property
def name(self):
return self.fields.get('name')
@name.setter
def name(self, name):
self.fields['name'] = name
@property
def email_address(self):
return self.fields.get('email_address')
@email_address.setter
def email_address(self, email_address):
self.fields['email_address'] = email_address
@property
def mobile_number(self):
return self.fields.get('mobile_number')
@mobile_number.setter
def mobile_number(self, mobile_number):
self.fields['mobile_number'] = mobile_number
@property
def password_changed_at(self):
return self.fields.get('password_changed_at')
self._state = fields.get('state')
self.max_failed_login_count = max_failed_login_count
def get_id(self):
return self.id
@@ -137,13 +112,53 @@ class User(object):
def is_active(self):
return self.state == 'active'
@property
def id(self):
return self._id
@id.setter
def id(self, id):
self._id = id
@property
def name(self):
return self._name
@name.setter
def name(self, name):
self._name = name
@property
def email_address(self):
return self._email_address
@email_address.setter
def email_address(self, email_address):
self._email_address = email_address
@property
def mobile_number(self):
return self._mobile_number
@mobile_number.setter
def mobile_number(self, mobile_number):
self._mobile_number = mobile_number
@property
def password_changed_at(self):
return self._password_changed_at
@password_changed_at.setter
def password_changed_at(self, password_changed_at):
self._password_changed_at = password_changed_at
@property
def state(self):
return self.fields['state']
return self._state
@state.setter
def state(self, state):
self.fields['state'] = state
self._state = state
@property
def failed_login_count(self):
@@ -157,10 +172,19 @@ class User(object):
return False
def is_locked(self):
return self.failed_login_count > self.max_failed_login_count
return self.failed_login_count >= self.max_failed_login_count
def serialize(self):
return self.fields
dct = {"id": self.id,
"name": self.name,
"email_address": self.email_address,
"mobile_number": self.mobile_number,
"password_changed_at": self.password_changed_at,
"state": self.state,
"failed_login_count": self.failed_login_count}
if getattr(self, '_password', None):
dct['password'] = self._password
return dct
def set_password(self, pwd):
self.fields['password'] = pwd
self._password = pwd

View File

@@ -56,7 +56,7 @@
Current service
</summary>
<div>
<a href="{{ url_for('main.choose_service') }}">Switch service</a>
<a href="{{ url_for('main.choose_service') }}">Switch service</a>
<a href="{{ url_for('.add_service') }}">Add a new service to GOV.UK Notify</a>
</div>
</details>
@@ -72,19 +72,13 @@
<main id="content" role="main" class="page-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<ul class="banner-dangerous">
{% for category, message in messages %}
<li class="flash-message">
{{ message }}
{% if 'delete' == category %}
<form method='post'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" class="button" name="delete" value="Yes, delete this template" />
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% for category, message in messages %}
{{ banner(
message,
'default' if category == 'default' else 'dangerous',
delete_button="Yes, delete this template" if 'delete' == category else None
)}}
{% endfor %}
{% endif %}
{% endwith %}
{% block fullwidth_content %}{% endblock %}

View File

@@ -1,5 +1,11 @@
{% macro banner(body, type=None, with_tick=False) %}
{% macro banner(body, type=None, with_tick=False, delete_button=None) %}
<div class='banner{% if type %}-{{ type }}{% endif %}{% if with_tick %}-with-tick{% endif %}'>
{{ body }}
{% if delete_button %}
<form method='post'>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" class="button" name="delete" value="{{ delete_button }}" />
</form>
{% endif %}
</div>
{% endmacro %}

View File

@@ -14,7 +14,7 @@
</h3>
{% endif %}
{% if input_name %}
<input class="sms-message-picker" type="radio" id="{{ input_name }}-{{ input_index }}" name="{{ input_name }}" />
<input class="sms-message-picker" type="radio" id="{{ input_name }}-{{ input_index }}" name="{{ input_name }}" value="{{ input_index }}" />
{% endif %}
<div class="sms-message-wrapper{% if input_name %}-with-radio{% endif %}">
{{ body|placeholders }}

View File

@@ -3,9 +3,9 @@
<li><a href="{{ url_for('.service_dashboard', service_id=service_id) }}">Dashboard</a></li>
</ul>
<ul>
<!--<li><a href="{{ url_for('.send_sms', service_id=service_id) }}">Send text messages</a></li>-->
<!--<li><a href="{{ url_for('.send_email', service_id=service_id) }}">Send emails</a></li>-->
<!--<li><a href="{{ url_for('.view_jobs', service_id=service_id) }}">Activity</a></li>-->
<li><a href="{{ url_for('.send_sms', service_id=service_id) }}">Send text messages</a></li>
<li><a href="{{ url_for('.send_email', service_id=service_id) }}">Send emails</a></li>
<li><a href="{{ url_for('.view_jobs', service_id=service_id) }}">Activity</a></li>
<li><a href="{{ url_for('.manage_service_templates', service_id=service_id) }}">Templates</a></li>
</ul>
<ul>
@@ -13,7 +13,7 @@
<li><a href="{{ url_for('.api_keys', service_id=service_id) }}">API keys</a></li>
</ul>
<ul>
<!--<li><a href="{{ url_for('.manage_users', service_id=service_id) }}">Manage users</a></li>-->
<li><a href="{{ url_for('.manage_users', service_id=service_id) }}">Manage users</a></li>
<li><a href="{{ url_for('.service_settings', service_id=service_id) }}">Service settings</a></li>
</ul>
</nav>

View File

@@ -12,14 +12,14 @@
</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.
To connect to the API you will need to create an API Key. Each service can have multiple API Keys to allow
for test and live environments.
</p>
<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.
API usage is described in
<a href="{{ url_for('.documentation', service_id=service_id) }}">the
developer documentation</a>.
</p>
<h2 class="api-key-name">

View File

@@ -3,47 +3,162 @@
{% from "components/api-key.html" import api_key %}
{% block page_title %}
GOV.UK Notify | API keys and documentation
GOV.UK Notify | API keys and documentation
{% endblock %}
{% block maincolumn_content %}
<div class="grid-row">
<div class="column-two-thirds">
<div class="grid-row">
<div class="column-two-thirds">
<h1 class="heading-xlarge">
Developer documentation
Developer documentation
</h1>
<h2 class="heading-medium">
How to integrate GOV.UK Notify into your service
How to integrate GOV.UK Notify into your service
</h2>
<p>
blah blah blah this is where we tell you how the API works
Notify provides an API that allows the creation of text notifications and the ability to get the status of a
sent notification.
</p>
<h2 class="heading-medium">Repositories</h2>
<h3 class="heading-medium">
API authentication
</h3>
<p>
<a href="https://github.com/alphagov/notifications-api">GOV.UK Notify API</a>
Notify uses <a href="https://jwt.io/">JSON Web Tokens (JWT)</a> for authentication.
JWT tokens have a series of claims, standard and application specific.
</p>
<p>Notify standard claims:</p>
<pre>
typ: JWT
alg: HS256
</pre>
<p>Notify application specific:</p>
<pre>
iss: service id
iat: creation time in epoch seconds (UTC)
req: signed request
pay: signed payload (POST requests only)
</pre>
<p>Notify API tokens sign both the request being made, and for POST requests, the payload.</p>
<p>
The signing algorithm is HMAC signature, using provided key SHA256 hashing algorithm.
</p>
<p>Request signing is of the form HTTP METHOD PATH.</p>
<code>GET /notification/1234</code>
<p></p>
<p>Payload signing requires the actual payload to be signed, NOT the JSON object. Serialize the object first
then sign the serialized object.</p>
<h3 class="heading-medium">
API endpoints
</h3>
<p>To create a text notification</p>
<code>POST /notifications/sms</code>
<p>
<pre>
{
'to': '+441234123123',
'template': 1
}
</pre>
Where 'to' is the phone number and 'template' is the template id to send.
</p>
<p>
<a href="https://github.com/alphagov/notify-api-client">GOV.UK Notify Python client</a>
Response:
<pre>
{
'notification':
{
'to': '+441234123123',
'createdAt': '2016-01-01T09:00:00.999999Z',
'status': 'created',
'id': 1,
'message': '....',
'method': 'sms',
'jobId': 1
}
}
</pre>
</p>
<p>To get the status of a text notification</p>
<p>
<code>
GET /notifications/{id}
</code>
</p>
<p>
<pre>
{
'notification':
{
'status': 'sent',
'createdAt': '2016-01-01T09:00:00.999999Z',
'to': '+447827992607',
'method': 'sms',
'sentAt': '2016-01-01T09:01:00.999999Z',
'id': 1,
'message': '...',
'jobId': 1,
'sender': 'sms-partner'
}
}
</pre>
</p>
<h2 class="heading-medium">
API client libraries
</h2>
<p>A python client library is support by the Notify team:</p>
<p>
<a href="https://github.com/alphagov/notifications-python-client">GOV.UK Notify Python client</a>
</p>
<p>
This provides example code for calling the API and for constructing the API tokens.
</p>
<h2 class="heading-medium">API code</h2>
<p>Notify API code is open sourced at:</p>
<p>
<a href="https://github.com/alphagov/notifications-api">GOV.UK Notify API</a>
</p>
<h2 class="heading-medium">API endpoint</h2>
<p>
https://www.notify.works/api/endpoint
https://www.notify.works/api/endpoint
</p>
<p>
<a href="{{ url_for('.api_keys', service_id=service_id) }}">API keys for your service</a>
<a href="{{ url_for('.api_keys', service_id=service_id) }}">API keys for your service</a>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -17,9 +17,9 @@
<fieldset class='form-group'>
<legend class="heading-medium">1. Choose text message template</legend>
{% for template in message_templates %}
{% for template in templates %}
{{ sms_message(
template.body, name=template.name, input_name='template', input_index=loop.index
template.content, name=template.name, input_name='template', input_index=template.id
) }}
{% endfor %}
</fieldset>

View File

@@ -24,7 +24,7 @@
'title': 'Temporarily suspend API keys',
'link': url_for('.service_status_change', service_id=service_id),
'destructive': True
} if service.active else {
} if not service.active else {
'title': 'Reactivate API keys',
'link': url_for('.service_status_change', service_id=service_id)
},

View File

@@ -22,14 +22,25 @@
<p><a href="https://github.com/alphagov/notifications-admin/blob/master/app/templates/views/styleguide.html">View source</a></p>
<h2 class="heading-large">Banner</h2>
<p>Used to show the result of a users action.</p>
{{ banner("This is a banner", with_tick=True) }}
<p>Used to show the status of a thing or action.</p>
{{ banner("You sent 1,234 text messages", with_tick=True) }}
<div class="grid-row">
<div class="column-one-third">
{{ banner("Delivered 10:20") }}
</div>
</div>
{{ banner(
'Your service is in restricted mode. You can only send notifications to yourself.',
'info'
) }}
{{ banner('Youre not allowed to do this', 'dangerous')}}
{{ banner('Are you sure you want to delete?', 'dangerous', delete_button="Yes, delete this thing")}}
<h2 class="heading-large">Big number</h2>
<p>Used to show some important statistics.</p>