redirect on login; flash errors on failure

the js `fetch` function will follow redirects blindly and return you the
final 200 response. when there's an error, we don't want to go anywhere,
and we want to use the flask `flash` functionality to pop up an error
page (the likely reason for seeing this is using a yubikey that isn't
associated with your user). using `flash` and then
`window.location.reload()` handles this fine.

However, when the user does log in succesfully we need to properly log
them in - this includes:

* checking their account isn't over the max login count
* resetting failed login count to 0 if not
* setting a new session id in the database (so other browser windows are
  logged out)
* checking if they need to revalidate their email access (every 90 days)
* clearing old user out of the cache

This code all happens in the ajax function rather than being in a
separate redirect, so that you can't just navigate to the login flow. I
wasn't able to unit test that function due how it uses the session and
other flask globals, so moved the auth into its own function so it's
easy to stub out all that CBOR nonsense.

TODO: We still need to pass any `next` URLs through the chain from login
page all the way through the javascript AJAX calls and redirects to the
log_in_user function
This commit is contained in:
Leo Hemsted
2021-05-17 15:56:15 +01:00
parent d9fd37a485
commit 92f78b14fe
6 changed files with 204 additions and 11 deletions

View File

@@ -33,10 +33,21 @@
});
})
.then(response => {
if (response.status === 403){
// flask will have `flash`ed an error message up
window.location.reload();
return;
}
if (!response.ok) {
// probably an internal server error
throw Error(response.statusText);
}
// TODO: redirect
// fetch will already have done the login redirect dance and will at this point be
// referring to the final 200 - hopefully to the `/accounts` url or similar. Set the location
// to trigger a browser navigate to that URL.
window.location.href = response.url;
})
.catch(error => {
console.error(error);

View File

@@ -1,14 +1,19 @@
from fido2 import cbor
from fido2.client import ClientData
from fido2.ctap2 import AuthenticatorData
from flask import abort, current_app, request, session
from flask import abort, current_app, flash, redirect, request, session, url_for
from flask_login import current_user
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 redirect_to_sign_in, 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')
@@ -90,6 +95,12 @@ def webauthn_complete_authentication():
if not user_to_login.platform_admin:
abort(403)
_complete_webauthn_authentication(user_to_login)
return _verify_webauthn_login(user_to_login)
def _complete_webauthn_authentication(user):
state = session.pop("webauthn_authentication_state")
request_data = cbor.decode(request.get_data())
@@ -98,7 +109,7 @@ def webauthn_complete_authentication():
state=state,
credentials=[
credential.to_credential_data()
for credential in user_to_login.webauthn_credentials
for credential in user.webauthn_credentials
],
credential_id=request_data['credentialId'],
client_data=ClientData(request_data['clientDataJSON']),
@@ -106,8 +117,31 @@ def webauthn_complete_authentication():
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}')
current_app.logger.info(f'User {user.id} could not sign in using their webauthn token - {exc}')
flash('Security key not recognised')
# TODO: increment failed login count
abort(403)
from app.main.views.two_factor import log_in_user
return log_in_user(user_id)
def _verify_webauthn_login(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.verify_webauthn_login()
if not logged_in:
# user account is locked as too many failed logins
flash('Security key not recognised')
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)

View File

@@ -430,6 +430,9 @@ class User(JSONModel, UserMixin):
self.id,
)
def verify_webauthn_login(self, is_successful=True):
return user_api_client.verify_webauthn_login(self.id, is_successful)
class InvitedUser(JSONModel):

View File

@@ -125,6 +125,18 @@ class UserApiClient(NotifyAdminAPIClient):
return False, e.message
raise e
@cache.delete('user-{user_id}')
def verify_webauthn_login(self, user_id, is_successful):
data = {'successful': is_successful}
endpoint = f'/user/{user_id}/verify/webauthn-login'
try:
self.post(endpoint, data=data)
return True, ''
except HTTPError as e:
if e.status_code == 403:
return False, e.message
raise e
def get_users_for_service(self, service_id):
endpoint = '/service/{}/users'.format(service_id)
return self.get(endpoint)['data']