Merge pull request #3894 from alphagov/webauthn-login-python-tests

Webauthn login
This commit is contained in:
David McDonald
2021-06-02 15:30:36 +01:00
committed by GitHub
23 changed files with 919 additions and 83 deletions

View File

@@ -43,7 +43,7 @@ def check_and_resend_verification_code():
if user.state == 'pending':
return redirect(url_for('main.verify', next=redirect_url))
else:
return redirect(url_for('main.two_factor', next=redirect_url))
return redirect(url_for('main.two_factor_sms', next=redirect_url))
@main.route('/email-not-received', methods=['GET'])

View File

@@ -45,10 +45,10 @@ def new_password(token):
# they've just clicked an email link, so have done an email auth journey anyway. Just log them in.
return log_in_user(user.id)
elif user.webauthn_auth:
raise NotImplementedError('webauthn not supported yet')
return redirect(url_for('main.two_factor_webauthn', next=request.args.get('next')))
else:
# send user a 2fa sms code
user.send_verify_code()
return redirect(url_for('main.two_factor', next=request.args.get('next')))
return redirect(url_for('main.two_factor_sms', next=request.args.get('next')))
else:
return render_template('views/new-password.html', token=token, form=form, user=user)

View File

@@ -46,11 +46,11 @@ def sign_in():
invited_user.accept_invite()
if user and user.sign_in():
if user.sms_auth:
return redirect(url_for('.two_factor', next=redirect_url))
return redirect(url_for('.two_factor_sms', next=redirect_url))
if user.email_auth:
return redirect(url_for('.two_factor_email_sent', next=redirect_url))
if user.webauthn_auth:
raise NotImplementedError('webauthn not supported yet')
return redirect(url_for('.two_factor_webauthn', next=redirect_url))
# Vague error message for login in case of user not known, locked, inactive or password not verified
flash(Markup(

View File

@@ -60,9 +60,10 @@ def two_factor_email(token):
return log_in_user(user_id)
@main.route('/two-factor-sms', methods=['GET', 'POST'])
@main.route('/two-factor', methods=['GET', 'POST'])
@redirect_to_sign_in
def two_factor():
def two_factor_sms():
user_id = session['user_details']['id']
user = User.from_id(user_id)
@@ -79,7 +80,15 @@ def two_factor():
user_api_client.send_verify_code(user.id, 'email', None, redirect_url)
return redirect(url_for('.revalidate_email_sent', next=redirect_url))
return render_template('views/two-factor.html', form=form, redirect_url=redirect_url)
return render_template('views/two-factor-sms.html', form=form, redirect_url=redirect_url)
@main.route('/two-factor-webauthn', methods=['GET'])
@redirect_to_sign_in
def two_factor_webauthn():
# TODO: Return a sensible error page if the user isn't platform admin or doesn't have webauthn
redirect_url = request.args.get('next')
return render_template('views/two-factor-webauthn.html', redirect_url=redirect_url)
@main.route('/re-validate-email', methods=['GET'])

View File

@@ -36,7 +36,7 @@ def verify():
finally:
session.pop('user_details', None)
return render_template('views/two-factor.html', form=form)
return render_template('views/two-factor-sms.html', form=form)
@main.route('/verify-email/<token>')

View File

@@ -1,11 +1,20 @@
from fido2 import cbor
from flask import current_app, request, session
from fido2.client import ClientData
from fido2.ctap2 import AuthenticatorData
from flask import abort, current_app, flash, redirect, request, session, url_for
from flask_login import current_user
from werkzeug.exceptions import Forbidden
from app.main import main
from app.main.views.two_factor import log_in_user
from app.models.user import User
from app.models.webauthn_credential import RegistrationError, WebAuthnCredential
from app.notify_client.user_api_client import user_api_client
from app.utils import user_is_platform_admin
from app.utils import (
is_less_than_days_ago,
redirect_to_sign_in,
user_is_platform_admin,
)
@main.route('/webauthn/register')
@@ -19,10 +28,7 @@ def webauthn_begin_register():
"name": current_user.email_address,
"displayName": current_user.name,
},
credentials=[
credential.to_credential_data()
for credential in current_user.webauthn_credentials
],
credentials=current_user.webauthn_credentials_as_cbor,
user_verification="discouraged", # don't ask for PIN
authenticator_attachment="cross-platform",
)
@@ -50,3 +56,103 @@ def webauthn_complete_register():
)
return cbor.encode('')
@main.route('/webauthn/authenticate', methods=['GET'])
@redirect_to_sign_in
def webauthn_begin_authentication():
# get user from session
user_to_login = User.from_id(session['user_details']['id'])
if not user_to_login.webauthn_auth:
abort(403)
if not user_to_login.platform_admin:
abort(403)
authentication_data, state = current_app.webauthn_server.authenticate_begin(
credentials=user_to_login.webauthn_credentials_as_cbor,
user_verification=None, # required, preferred, discouraged. sets whether to ask for PIN
)
session["webauthn_authentication_state"] = state
return cbor.encode(authentication_data)
@main.route('/webauthn/authenticate', methods=['POST'])
@redirect_to_sign_in
def webauthn_complete_authentication():
user_id = session['user_details']['id']
user_to_login = User.from_id(user_id)
if not user_to_login.webauthn_auth:
abort(403)
if not user_to_login.platform_admin:
abort(403)
try:
_verify_webauthn_authentication(user_to_login)
redirect = _complete_webauthn_login_attempt(user_to_login)
except Forbidden:
# We don't expect to reach this case in normal situations - normally errors (such as using the wrong
# security key) will be caught in the browser inside `window.navigator.credentials.get`, and the js will
# error first meaning it doesn't send the POST request to this method. If this method is called but the key
# couldn't be authenticated, something went wrong along the way, probably:
# * The browser didn't implement the webauthn standard correctly, and let something through it shouldn't have
# * The key itself is in some way corrupted, or of lower security standard
flash('Security key not recognised')
# flash sets the error message in the user's session cookie, and flask renders it next time `render_template`
# is called. In authenticateSecurityKey.js we refresh the page if this POST returns a 403.
# we can't use `abort(403)` here, and just return an empty body instead as our 403 error handler would return
# an error page response containing the flash, but our javascript ignores the body of the error response and
# just looks at the error code
return '', 403
return cbor.encode({'redirect_url': redirect.location}), 200
def _verify_webauthn_authentication(user):
"""
Check that the presented security key is valid, has signed the right challenge, and belongs to the user
we're trying to log in.
"""
state = session.pop("webauthn_authentication_state")
request_data = cbor.decode(request.get_data())
try:
current_app.webauthn_server.authenticate_complete(
state=state,
credentials=user.webauthn_credentials_as_cbor,
credential_id=request_data['credentialId'],
client_data=ClientData(request_data['clientDataJSON']),
auth_data=AuthenticatorData(request_data['authenticatorData']),
signature=request_data['signature']
)
except ValueError as exc:
current_app.logger.info(f'User {user.id} could not sign in using their webauthn token - {exc}')
user.complete_webauthn_login_attempt(is_successful=False)
abort(403)
def _complete_webauthn_login_attempt(user):
"""
* check the user hasn't gone over their max logins
* check that the user's email is validated
* if succesful, update current_session_id, log in date, and then redirect
"""
redirect_url = request.args.get('next')
# normally API handles this when verifying an sms or email code but since the webauthn logic happens in the
# admin we need a separate call that just finalises the login in the database
logged_in, _ = user.complete_webauthn_login_attempt()
if not logged_in:
# user account is locked as too many failed logins
abort(403)
if not is_less_than_days_ago(user.email_access_validated_at, 90):
user_api_client.send_verify_code(user.id, 'email', None, redirect_url)
return redirect(url_for('.revalidate_email_sent', next=redirect_url))
return log_in_user(user.id)