authentic/src/authentic2_auth_fc/views.py

652 lines
26 KiB
Python

# authentic2-auth-fc - authentic2 authentication for FranceConnect
# Copyright (C) 2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import logging
import time
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.views import update_session_auth_hash
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError, transaction
from django.forms import Form
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django.views.generic import FormView, View
from requests_oauthlib import OAuth2Session
from authentic2 import app_settings as a2_app_settings
from authentic2 import constants
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.forms.passwords import SetPasswordForm
from authentic2.models import Attribute, AttributeValue, Lock
from authentic2.utils import hooks
from authentic2.utils import misc as utils_misc
from authentic2.utils import views as utils_views
from authentic2.utils.crypto import check_hmac_url, hash_chain, hmac_url
from . import app_settings, models, utils
from .utils import (
RequestError,
apply_user_info_mappings,
build_logout_url,
clean_fc_session,
get_json,
post_json,
)
logger = logging.getLogger(__name__)
User = get_user_model()
class UserOutsideDefaultOu(Exception):
pass
def login(request, *args, **kwargs):
if 'nofc' in request.GET:
return
fc_user_info = request.session.get('fc_user_info')
context = kwargs.pop('context', {}).copy()
context.update(
{
'about_url': app_settings.about_url,
'fc_user_info': fc_user_info,
}
)
context['login_url'] = utils_misc.make_url('fc-login-or-link', keep_params=True, request=request)
context['block-extra-css-class'] = 'fc-login'
template = 'authentic2_auth_fc/login.html'
return TemplateResponse(request, template, context)
def profile(request, *args, **kwargs):
# We prevent unlinking if the user has no usable password and can't change it
# because we assume that the password is the unique other mean of authentication
# and unlinking would make the account unreachable.
unlink = request.user.has_usable_password() or a2_app_settings.A2_REGISTRATION_CAN_CHANGE_PASSWORD
account_path = utils_misc.reverse('account_management')
params = {
'next': account_path,
}
link_url = utils_misc.make_url('fc-login-or-link', params=params)
context = kwargs.pop('context', {}).copy()
context.update(
{
'unlink': unlink,
'about_url': app_settings.about_url,
'link_url': link_url,
}
)
return render_to_string('authentic2_auth_fc/linking.html', context, request=request)
class LoginOrLinkView(View):
"""Login with FC, if the FC account is already linked, connect this user,
if a user is logged link the user to this account, otherwise display an
error message.
"""
_next_url = None
display_message_on_redirect = False
@property
def next_url(self):
return self._next_url or utils_misc.select_next_url(self.request, default=settings.LOGIN_REDIRECT_URL)
@property
def redirect_uri(self):
return self.request.build_absolute_uri(reverse('fc-login-or-link'))
def redirect(self):
return utils_misc.redirect(self.request, self.next_url)
def logout_and_redirect(self):
url = utils.build_logout_url(self.request, self.authenticator.logout_url, next_url=self.next_url)
if url:
clean_fc_session(self.request.session)
response = utils_misc.redirect(self.request, url, resolve=False)
response.display_message = False
return response
return self.redirect()
@property
def fc_display_name(self):
'''Human representation of the current FC account'''
display_name = ''
family_name = self.user_info.get('family_name')
given_name = self.user_info.get('given_name')
if given_name:
display_name += given_name
if family_name:
if display_name:
display_name += ' '
display_name += family_name
return display_name
def get(self, request, *args, **kwargs):
self.authenticator = get_object_or_404(models.FcAuthenticator, enabled=True)
code = request.GET.get('code')
state = request.GET.get('state')
if code and state:
response = self.handle_authorization_response(request, code=code, state=state)
response.delete_cookie('fc-state', path=reverse('fc-login-or-link'))
return response
elif 'error' in request.GET:
return self.authorization_error(
request, error=request.GET['error'], error_description=request.GET.get('error_description')
)
else:
return self.make_authorization_request(request)
def handle_authorization_response(self, request, code, state):
# check state signature and parse it
try:
state, self._next_url = self.decode_state(state)
except ValueError:
return utils_misc.redirect(request, settings.LOGIN_REDIRECT_URL)
# regenerte the chain of hash from the stored nonce_seed
try:
encoded_seed = request.COOKIES.get('fc-state', '')
if not encoded_seed:
raise ValueError
dummy, hash_nonce, hash_state = hash_chain(3, encoded_seed=encoded_seed)
if not state or state != hash_state:
logger.warning('auth_fc: state lost, requesting authorization again')
raise ValueError
except ValueError:
return self.make_authorization_request(request)
# resolve the authorization_code and check the token endpoint response
self.token = self.resolve_authorization_code(code)
if not self.token:
# resolve_authorization_code already logged a warning.
return self.report_fc_is_down(request)
if 'error' in self.token:
logger.warning('auth_fc: token request failed, "%s"', self.token)
messages.warning(
request,
_('Unable to connect to FranceConnect: "%s".')
% (self.token.get('error_description') or self.token['error']),
)
return self.redirect()
# parse the id_token
if not self.token.get('id_token') or not isinstance(self.token['id_token'], str):
logger.warning('auth_fc: token endpoint did not return an id_token')
return self.report_fc_is_down(request)
key = self.authenticator.client_secret.encode()
self.id_token, error = utils.parse_id_token(
self.token['id_token'],
self.authenticator.authorize_url,
client_id=self.authenticator.client_id,
client_secret=key,
)
if not self.id_token:
logger.warning('auth_fc: validation of id_token failed: %s', error)
return self.report_fc_is_down(request)
logger.debug('auth_fc: parsed id_token %s', self.id_token)
nonce = self.id_token.get('nonce')
if nonce != hash_nonce:
logger.warning('auth_fc: invalid nonce in id_token')
return self.report_fc_is_down(request)
self.sub = self.id_token.get('sub')
if not self.sub:
logger.warning('auth_fc: no sub in id_token %s', self.id_token)
return self.report_fc_is_down(request)
# get user info using the access token
if not self.token.get('access_token') or not isinstance(self.token['access_token'], str):
logger.warning('auth_fc: token endpoint did not return an access_token')
return self.report_fc_is_down(request)
self.user_info = self.get_user_info()
if self.user_info is None:
return self.report_fc_is_down(request)
logger.debug('auth_fc: user_info %s', self.user_info)
# clear FranceConnect down status
cache.delete('fc_is_down')
# keep id_token around for logout
request.session['fc_id_token'] = self.id_token
request.session['fc_id_token_raw'] = self.token['id_token']
if request.user.is_authenticated:
return self.link(request)
else:
return self.login(request)
def encode_state(self, state, next_url):
encoded_state = state + ' ' + self.next_url
encoded_state += ' ' + hmac_url(settings.SECRET_KEY, encoded_state)
return encoded_state
def decode_state(self, state):
payload, signature = state.rsplit(' ', 1)
if not check_hmac_url(settings.SECRET_KEY, payload, signature):
raise ValueError
state, next_url, *dummy = payload.split(' ')
return state, next_url
def make_authorization_request(self, request):
scope = ' '.join(set(['openid'] + self.authenticator.scopes))
nonce_seed, nonce, state = hash_chain(3)
# encode the target service and next_url in the state
full_state = state + ' ' + self.next_url + ' '
full_state += ' ' + hmac_url(settings.SECRET_KEY, full_state)
params = {
'client_id': self.authenticator.client_id,
'scope': scope,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'state': self.encode_state(state, self.next_url),
'nonce': nonce,
'acr_values': 'eidas1',
}
url = f'{self.authenticator.authorize_url}?{urlencode(params)}'
logger.debug('auth_fc: authorization_request redirect to %s', url)
response = HttpResponseRedirect(url)
# prevent unshown messages to block the navigation to FranceConnect
response.display_message = self.display_message_on_redirect
# store nonce_seed in a browser cookie to prevent CSRF and check nonce
# in id_token on return by generating the hash chain again
response.set_cookie(
'fc-state',
value=nonce_seed,
path=reverse('fc-login-or-link'),
httponly=True,
secure=request.is_secure(),
samesite='Lax',
)
return response
def resolve_authorization_code(self, authorization_code):
'''Exchange an authorization_code for an access_token'''
data = {
'code': authorization_code,
'client_id': self.authenticator.client_id,
'client_secret': self.authenticator.client_secret,
'redirect_uri': self.redirect_uri,
'grant_type': 'authorization_code',
}
logger.debug('auth_fc: resolve_access_token request params %s', data)
try:
token = post_json(self.authenticator.token_url, data, expected_statuses=[400])
except RequestError as e:
logger.warning('auth_fc: resolve_authorization_code error %s', e)
return None
else:
logger.debug('auth_fc: token endpoint returned "%s"', token)
return token
def get_user_info(self):
try:
data = get_json(
self.authenticator.userinfo_url + '?schema=openid',
session=OAuth2Session(self.authenticator.client_id, token=self.token),
)
except RequestError as e:
logger.warning('auth_fc: get_user_info error %s', e)
return None
logger.debug('auth_fc: get_user_info returned %r', data)
return data
def authorization_error(self, request, error, error_description):
messages.error(request, _('Unable to connect to FranceConnect: "%s".') % (error_description or error))
logger.warning(
'auth_fc: authorization failed with error=%r error_description=%r', error, error_description or ''
)
return self.redirect()
def report_fc_is_down(self, request):
messages.warning(request, _('Unable to connect to FranceConnect.'))
# put FranceConnect status in cache, if it happens for more than 5 minutes, log an error
last_down = cache.get('fc_is_down')
now = time.time()
more_than_5_minutes = last_down and (now - last_down) > 5 * 60
if more_than_5_minutes:
logger.error('auth_fc: FranceConnect is down for more than 5 minutes')
if not last_down or more_than_5_minutes:
cache.set('fc_is_down', now, 10 * 60)
return self.redirect()
def link(self, request):
'''Request an access grant code and associate it to the current user'''
try:
self.fc_account, created = models.FcAccount.objects.get_or_create(
sub=self.sub,
user=request.user,
order=0,
defaults={
'token': json.dumps(self.token),
'user_info': json.dumps(self.user_info),
},
)
# Prevent adding a link with an FC account already linked with another user.
except IntegrityError:
# unique index check failed, find why.
return self.uniqueness_check_failed(request)
if created:
logger.info('auth_fc: link created sub %s', self.sub)
messages.info(
request, _('Your FranceConnect account {} has been linked.').format(self.fc_display_name)
)
hooks.call_hooks('event', name='fc-link', user=request.user, sub=self.sub, request=request)
else:
if self.fc_account.created <= request.user.last_login:
utils_misc.record_authentication_event(request, 'france-connect')
self.update_user_info(request.user, self.user_info)
return self.redirect()
@transaction.atomic
def login(self, request):
user = utils_misc.authenticate(request, sub=self.sub, user_info=self.user_info, token=self.token)
if not user:
user, created = self.create_account(
request, sub=self.sub, token=self.token, user_info=self.user_info
)
else:
created = False
if not user:
return self.logout_and_redirect()
return self.finish_login(request, user, self.user_info, created)
def finish_login(self, request, user, user_info, created):
self.update_user_info(user, user_info)
utils_views.check_cookie_works(request)
utils_misc.login(request, user, 'france-connect')
# set session expiration policy to EXPIRE_AT_BROWSER_CLOSE
request.session.set_expiry(0)
# redirect to account edit page if any required attribute is not filled
# only on user registration
missing = created and self.missing_required_attributes(user)
if missing:
messages.warning(
request,
_('The following fields are mandatory for account creation: %s') % ', '.join(missing),
)
return utils_misc.redirect(request, 'profile_edit', params={'next': self.next_url})
return self.redirect()
def missing_required_attributes(self, user):
'''Compute if user has not filled some required attributes.'''
name_to_label = dict(
Attribute.objects.filter(required=True, user_editable=True).values_list('name', 'label')
)
required = list(a2_app_settings.A2_REGISTRATION_REQUIRED_FIELDS) + list(name_to_label)
missing = []
for attr_name in set(required):
value = getattr(user, attr_name, None) or getattr(user.attributes, attr_name, None)
if value in [None, '']:
missing.append(name_to_label[attr_name])
return missing
def create_account(self, request, sub, token, user_info):
email = user_info.get('email')
if email:
# try to create or find an user with this email
try:
user, created = self.get_or_create_user_with_email(email)
except UserOutsideDefaultOu:
user = None
except User.MultipleObjectsReturned:
user = None
if not user:
messages.warning(
request,
_(
'Your FranceConnect email address \'%s\' is already used by another account, so we'
' cannot create an account for you. Please connect with you existing account or'
' create an account with another email address then link it to FranceConnect using'
' your account management page.'
)
% email,
)
return None, False
if not created and user.fc_accounts.exists():
messages.warning(
request,
_(
'Your FranceConnect email address "%(email)s" is already used by the FranceConnect'
' account of "%(user)s", so we cannot create an account for you. Please create an'
' account with another email address then link it to FranceConnect using your account'
' management page.'
)
% {'email': email, 'user': user.get_full_name()},
)
else: # no email, we cannot disembiguate users, let's create it anyway
user = User.objects.create(ou=get_default_ou())
created = True
try:
if created:
user.set_unusable_password()
user.save()
# As we intercept IntegrityError and we can never be sure if we are
# in a transaction or not, we must use one to prevent later SQL
# queries to fail.
with transaction.atomic():
models.FcAccount.objects.create(
user=user,
sub=sub,
order=0,
token=json.dumps(token),
user_info=json.dumps(user_info),
)
except IntegrityError:
# uniqueness check failed, as the user is new, it can only mean that the sub is not unique
# let's try again
if created:
user.delete()
return utils_misc.authenticate(request, sub=sub, token=token, user_info=user_info), False
except Exception:
# if anything unexpected happen and user was created, delete it and re-raise
if created:
user.delete()
raise
else:
if created:
logger.info('auth_fc: new account "%s" created with FranceConnect sub "%s"', user, sub)
hooks.call_hooks('event', name='fc-create', user=user, sub=sub, request=request)
utils_misc.send_templated_mail(
user,
template_names=['authentic2_auth_fc/registration_success'],
context={
'login_url': request.build_absolute_uri(settings.LOGIN_URL),
},
request=self.request,
)
# FC account creation does not rely on the registration_completion generic view.
# Registration event has to be recorded here:
request.journal.record('user.registration', user=user, how='france-connect')
else:
logger.info('auth_fc: existing account "%s" linked to FranceConnect sub "%s"', user, sub)
hooks.call_hooks('event', name='fc-link', user=user, sub=sub, request=request)
authenticated_user = utils_misc.authenticate(request, sub=sub, user_info=user_info, token=token)
return authenticated_user, created
def uniqueness_check_failed(self, request):
# currently logged :
if models.FcAccount.objects.filter(user=request.user, order=0).count():
# cannot link because we are already linked to another FC account
messages.error(request, _('Your account is already linked to FranceConnect'))
else:
# cannot link because the FC account is already linked to another account.
messages.error(
request,
_('The FranceConnect identity {} is already linked to another account.').format(
self.fc_display_name
),
)
return self.logout_and_redirect()
def update_user_info(self, user, user_info):
# always handle given_name and family_name
updated = []
if user_info.get('given_name') and user.first_name != user_info['given_name']:
user.first_name = user_info['given_name']
updated.append('given name: "%s"' % user_info['given_name'])
if user_info.get('family_name') and user.last_name != user_info['family_name']:
user.last_name = user_info['family_name']
updated.append('family name: "%s"' % user_info['family_name'])
if updated:
user.save()
logger.debug('auth_fc: updated (%s)', ' - '.join(updated))
apply_user_info_mappings(user, user_info)
return user
def get_or_create_user_with_email(self, email):
ou = get_default_ou()
qs = User.objects
if not a2_app_settings.A2_EMAIL_IS_UNIQUE:
qs = qs.filter(ou=ou)
Lock.lock_email(email)
try:
user = qs.get_by_email(email)
except User.DoesNotExist:
return User.objects.create(ou=ou, email=email), True
if user.ou != ou:
raise UserOutsideDefaultOu
return user, False
login_or_link = LoginOrLinkView.as_view()
class UnlinkView(FormView):
template_name = 'authentic2_auth_fc/unlink.html'
def get_success_url(self):
url = reverse('account_management')
if app_settings.logout_when_unlink:
# logout URL can be None if not session exists with FC
authenticator = get_object_or_404(models.FcAuthenticator, enabled=True)
url = build_logout_url(self.request, authenticator.logout_url, next_url=url) or url
return url
def get_form_class(self):
form_class = Form
if self.must_set_password():
form_class = SetPasswordForm
return form_class
def get_form_kwargs(self, **kwargs):
kwargs = super().get_form_kwargs(**kwargs)
if self.must_set_password():
kwargs['user'] = self.request.user
return kwargs
def must_set_password(self):
for event in self.request.session.get(constants.AUTHENTICATION_EVENTS_SESSION_KEY, []):
if event['how'].startswith('password'):
return False
return self.request.user.can_change_password()
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
raise PermissionDenied()
# We prevent unlinking if the user has no usable password and can't change it
# because we assume that the password is the unique other mean of authentication
# and unlinking would make the account unreachable.
if self.must_set_password() and not a2_app_settings.A2_REGISTRATION_CAN_CHANGE_PASSWORD:
# Prevent access to the view.
raise Http404
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
if self.must_set_password():
form.save()
update_session_auth_hash(self.request, self.request.user)
logger.info('auth_fc: user %s has set a password', self.request.user)
links = models.FcAccount.objects.filter(user=self.request.user)
for link in links:
logger.info('auth_fc: user %s unlinked from %s', self.request.user, link)
hooks.call_hooks('event', name='fc-unlink', user=self.request.user)
messages.info(self.request, _('Your account link to FranceConnect has been deleted.'))
links.delete()
# FC mapping config may have changed over time, hence it is impossible to tell which
# attribute was verified at FC link time.
AttributeValue.objects.with_owner(self.request.user).update(verified=False)
response = super().form_valid(form)
if app_settings.logout_when_unlink:
response.display_message = False
clean_fc_session(self.request.session)
return response
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.must_set_password():
context['no_password'] = True
return context
def post(self, request, *args, **kwargs):
if 'cancel' in request.POST:
return utils_misc.redirect(request, 'account_management')
return super().post(request, *args, **kwargs)
unlink = UnlinkView.as_view()
class LogoutReturnView(View):
def get(self, request, *args, **kwargs):
state = request.GET.get('state')
clean_fc_session(request.session)
states = request.session.pop('fc_states', None)
next_url = None
if states and state in states:
next_url = states[state].get('next')
if not next_url:
next_url = reverse('auth_logout')
return HttpResponseRedirect(next_url)
logout = LogoutReturnView.as_view()