2021-05-07 18:10:07 +01:00
|
|
|
|
from fido2 import cbor
|
2021-05-14 17:37:57 +01:00
|
|
|
|
from fido2.client import ClientData
|
|
|
|
|
|
from fido2.ctap2 import AuthenticatorData
|
2021-05-17 15:56:15 +01:00
|
|
|
|
from flask import abort, current_app, flash, redirect, request, session, url_for
|
2021-05-07 18:10:07 +01:00
|
|
|
|
from flask_login import current_user
|
|
|
|
|
|
|
|
|
|
|
|
from app.main import main
|
2021-05-14 17:37:57 +01:00
|
|
|
|
from app.models.user import User
|
2021-05-14 09:17:12 +01:00
|
|
|
|
from app.models.webauthn_credential import RegistrationError, WebAuthnCredential
|
2021-05-07 18:10:07 +01:00
|
|
|
|
from app.notify_client.user_api_client import user_api_client
|
2021-06-14 12:40:12 +01:00
|
|
|
|
from app.utils.login import (
|
|
|
|
|
|
email_needs_revalidating,
|
|
|
|
|
|
log_in_user,
|
|
|
|
|
|
redirect_to_sign_in,
|
|
|
|
|
|
)
|
2021-10-05 09:11:02 +01:00
|
|
|
|
from app.utils.user import user_is_logged_in
|
2021-05-07 18:10:07 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.route('/webauthn/register')
|
2021-10-05 09:11:02 +01:00
|
|
|
|
@user_is_logged_in
|
2021-05-07 18:10:07 +01:00
|
|
|
|
def webauthn_begin_register():
|
2021-06-30 15:30:29 +01:00
|
|
|
|
if not current_user.can_use_webauthn:
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
|
2021-05-07 18:10:07 +01:00
|
|
|
|
server = current_app.webauthn_server
|
|
|
|
|
|
|
|
|
|
|
|
registration_data, state = server.register_begin(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": bytes(current_user.id, 'utf-8'),
|
|
|
|
|
|
"name": current_user.email_address,
|
|
|
|
|
|
"displayName": current_user.name,
|
|
|
|
|
|
},
|
2021-06-08 09:41:39 +01:00
|
|
|
|
credentials=current_user.webauthn_credentials.as_cbor,
|
2021-05-07 18:10:07 +01:00
|
|
|
|
user_verification="discouraged", # don't ask for PIN
|
|
|
|
|
|
authenticator_attachment="cross-platform",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
session["webauthn_registration_state"] = state
|
|
|
|
|
|
return cbor.encode(registration_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.route('/webauthn/register', methods=['POST'])
|
2021-10-05 09:11:02 +01:00
|
|
|
|
@user_is_logged_in
|
2021-05-07 18:10:07 +01:00
|
|
|
|
def webauthn_complete_register():
|
2021-05-14 09:17:12 +01:00
|
|
|
|
if 'webauthn_registration_state' not in session:
|
|
|
|
|
|
return cbor.encode("No registration in progress"), 400
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
credential = WebAuthnCredential.from_registration(
|
|
|
|
|
|
session.pop("webauthn_registration_state"),
|
|
|
|
|
|
cbor.decode(request.get_data()),
|
|
|
|
|
|
)
|
|
|
|
|
|
except RegistrationError as e:
|
2021-06-03 17:31:27 +01:00
|
|
|
|
current_app.logger.info(f'User {current_user.id} could not register a new webauthn token - {e}')
|
2021-09-14 14:31:45 +01:00
|
|
|
|
abort(400)
|
2021-05-07 18:10:07 +01:00
|
|
|
|
|
2021-06-07 13:51:39 +01:00
|
|
|
|
current_user.create_webauthn_credential(credential)
|
2021-06-07 13:53:33 +01:00
|
|
|
|
current_user.update(auth_type='webauthn_auth')
|
2021-05-07 18:10:07 +01:00
|
|
|
|
|
2021-06-01 17:07:23 +01:00
|
|
|
|
flash((
|
|
|
|
|
|
'Registration complete. Next time you sign in to Notify '
|
|
|
|
|
|
'you’ll be asked to use your security key.'
|
|
|
|
|
|
), 'default_with_tick')
|
|
|
|
|
|
|
2021-05-14 09:17:12 +01:00
|
|
|
|
return cbor.encode('')
|
2021-05-14 17:37:57 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.route('/webauthn/authenticate', methods=['GET'])
|
|
|
|
|
|
@redirect_to_sign_in
|
|
|
|
|
|
def webauthn_begin_authentication():
|
2021-06-03 16:58:59 +01:00
|
|
|
|
"""
|
|
|
|
|
|
Initiate the authentication flow. This is called after the user clicks the "Check security key" button.
|
|
|
|
|
|
|
|
|
|
|
|
1. Get the user's credentials out of the database to present to the browser. The browser will only let you use a
|
|
|
|
|
|
credential in that list.
|
|
|
|
|
|
2. Call webauthn_server.authenticate_begin. This returns the authentication data, which includes the challenge and
|
|
|
|
|
|
the origin domain to authenticate with. This also returns the state, which we store in the cookie so we can ensure
|
|
|
|
|
|
the challenge is correct in webauthn_complete_authentication
|
|
|
|
|
|
"""
|
2021-05-14 17:37:57 +01:00
|
|
|
|
# get user from session
|
|
|
|
|
|
user_to_login = User.from_id(session['user_details']['id'])
|
|
|
|
|
|
|
2021-05-14 18:14:13 +01:00
|
|
|
|
if not user_to_login.webauthn_auth:
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
|
2021-05-14 17:37:57 +01:00
|
|
|
|
authentication_data, state = current_app.webauthn_server.authenticate_begin(
|
2021-06-08 09:41:39 +01:00
|
|
|
|
credentials=user_to_login.webauthn_credentials.as_cbor,
|
2021-06-03 17:01:04 +01:00
|
|
|
|
user_verification="discouraged", # don't ask for PIN
|
2021-05-14 17:37:57 +01:00
|
|
|
|
)
|
|
|
|
|
|
session["webauthn_authentication_state"] = state
|
|
|
|
|
|
return cbor.encode(authentication_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.route('/webauthn/authenticate', methods=['POST'])
|
|
|
|
|
|
@redirect_to_sign_in
|
|
|
|
|
|
def webauthn_complete_authentication():
|
2021-06-03 16:58:59 +01:00
|
|
|
|
"""
|
|
|
|
|
|
Complete the authentication flow. This is called after the user taps on their security key.
|
|
|
|
|
|
|
|
|
|
|
|
1. Try verifying the signed challenge returned from the browser with each public key we have in the database for
|
|
|
|
|
|
that user.
|
|
|
|
|
|
2. If succesful, log the user in, setting up the session etc. Then return the URL they should be redirected to.
|
|
|
|
|
|
"""
|
2021-05-14 17:37:57 +01:00
|
|
|
|
user_id = session['user_details']['id']
|
|
|
|
|
|
user_to_login = User.from_id(user_id)
|
|
|
|
|
|
|
2021-09-14 14:31:45 +01:00
|
|
|
|
_verify_webauthn_authentication(user_to_login)
|
|
|
|
|
|
redirect = _complete_webauthn_login_attempt(user_to_login)
|
2021-05-17 15:56:15 +01:00
|
|
|
|
|
2021-05-25 15:50:14 +01:00
|
|
|
|
return cbor.encode({'redirect_url': redirect.location}), 200
|
2021-05-17 15:56:15 +01:00
|
|
|
|
|
|
|
|
|
|
|
2021-06-02 11:25:02 +01:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
2021-05-14 18:14:13 +01:00
|
|
|
|
state = session.pop("webauthn_authentication_state")
|
|
|
|
|
|
request_data = cbor.decode(request.get_data())
|
|
|
|
|
|
|
2021-05-14 17:37:57 +01:00
|
|
|
|
try:
|
|
|
|
|
|
current_app.webauthn_server.authenticate_complete(
|
|
|
|
|
|
state=state,
|
2021-06-08 09:41:39 +01:00
|
|
|
|
credentials=user.webauthn_credentials.as_cbor,
|
2021-05-14 17:37:57 +01:00
|
|
|
|
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:
|
2021-09-14 14:31:45 +01:00
|
|
|
|
# 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
|
2021-05-17 15:56:15 +01:00
|
|
|
|
current_app.logger.info(f'User {user.id} could not sign in using their webauthn token - {exc}')
|
2021-06-02 11:25:02 +01:00
|
|
|
|
user.complete_webauthn_login_attempt(is_successful=False)
|
2021-05-14 17:37:57 +01:00
|
|
|
|
abort(403)
|
|
|
|
|
|
|
2021-05-17 15:56:15 +01:00
|
|
|
|
|
2021-06-02 11:25:02 +01:00
|
|
|
|
def _complete_webauthn_login_attempt(user):
|
2021-05-17 15:56:15 +01:00
|
|
|
|
"""
|
|
|
|
|
|
* 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
|
2021-06-02 11:25:02 +01:00
|
|
|
|
logged_in, _ = user.complete_webauthn_login_attempt()
|
2021-05-17 15:56:15 +01:00
|
|
|
|
if not logged_in:
|
|
|
|
|
|
# user account is locked as too many failed logins
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
|
2021-06-14 12:40:12 +01:00
|
|
|
|
if email_needs_revalidating(user):
|
2021-05-17 15:56:15 +01:00
|
|
|
|
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)
|