mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-11 09:28:27 -04:00
Merge pull request #3894 from alphagov/webauthn-login-python-tests
Webauthn login
This commit is contained in:
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user