1309 lines
42 KiB
Python
1309 lines
42 KiB
Python
# authentic2 - versatile identity manager
|
|
# Copyright (C) 2010-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 copy
|
|
import ctypes
|
|
import logging
|
|
import random
|
|
import time
|
|
import uuid
|
|
from functools import wraps
|
|
from importlib import import_module
|
|
from itertools import chain, count, islice
|
|
|
|
from django import forms
|
|
from django.conf import settings
|
|
from django.contrib.auth import REDIRECT_FIELD_NAME
|
|
from django.contrib.auth import authenticate as dj_authenticate
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth import login as auth_login
|
|
from django.core import signing
|
|
from django.core.exceptions import ImproperlyConfigured
|
|
from django.core.mail import EmailMessage, send_mail
|
|
from django.forms.utils import ErrorList, to_current_timezone
|
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
from django.http.request import QueryDict
|
|
from django.shortcuts import render, resolve_url
|
|
from django.template.context import make_context
|
|
from django.template.loader import TemplateDoesNotExist, render_to_string, select_template
|
|
from django.urls import reverse
|
|
from django.utils import html, six, timezone
|
|
from django.utils.encoding import iri_to_uri, uri_to_iri
|
|
from django.utils.formats import localize
|
|
from django.utils.six.moves.urllib import parse as urlparse
|
|
from django.utils.translation import ungettext
|
|
|
|
try:
|
|
from django.core.exceptions import FieldDoesNotExist
|
|
except ImportError:
|
|
# Django < 1.8
|
|
from django.db.models.fields import FieldDoesNotExist
|
|
|
|
from authentic2.saml.saml2utils import filter_attribute_private_key, filter_element_private_key
|
|
|
|
from .. import app_settings, constants, crypto, plugins
|
|
from .service import set_service_ref
|
|
|
|
|
|
class CleanLogMessage(logging.Filter):
|
|
def filter(self, record):
|
|
record.msg = filter_attribute_private_key(record.msg)
|
|
record.msg = filter_element_private_key(record.msg)
|
|
return True
|
|
|
|
|
|
class MWT(object):
|
|
"""Memoize With Timeout"""
|
|
|
|
_caches = {}
|
|
_timeouts = {}
|
|
|
|
def __init__(self, timeout=2):
|
|
self.timeout = timeout
|
|
|
|
def collect(self):
|
|
"""Clear cache of results which have timed out"""
|
|
for func in self._caches:
|
|
cache = {}
|
|
for key in self._caches[func]:
|
|
if (time.time() - self._caches[func][key][1]) < self._timeouts[func]:
|
|
cache[key] = self._caches[func][key]
|
|
self._caches[func] = cache
|
|
|
|
def __call__(self, f):
|
|
self.cache = self._caches[f] = {}
|
|
self._timeouts[f] = self.timeout
|
|
|
|
def func(*args, **kwargs):
|
|
kw = kwargs.items()
|
|
kw.sort()
|
|
key = (args, tuple(kw))
|
|
try:
|
|
v = self.cache[key]
|
|
if (time.time() - v[1]) > self.timeout:
|
|
raise KeyError
|
|
except KeyError:
|
|
v = self.cache[key] = f(*args, **kwargs), time.time()
|
|
return v[0]
|
|
|
|
func.func_name = f.func_name
|
|
|
|
return func
|
|
|
|
|
|
def import_from(module, name):
|
|
module = __import__(module, fromlist=[name])
|
|
return getattr(module, name)
|
|
|
|
|
|
def get_session_store():
|
|
return import_module(settings.SESSION_ENGINE).SessionStore
|
|
|
|
|
|
def flush_django_session(django_session_key):
|
|
get_session_store()(session_key=django_session_key).flush()
|
|
|
|
|
|
class IterableFactory(object):
|
|
"""Return an new iterable using a generator function each time this object
|
|
is iterated."""
|
|
|
|
def __init__(self, f):
|
|
self.f = f
|
|
|
|
def __iter__(self):
|
|
return iter(self.f())
|
|
|
|
|
|
def accumulate_from_backends(request, method_name, **kwargs):
|
|
list = []
|
|
for backend in get_backends():
|
|
method = getattr(backend, method_name, None)
|
|
if callable(method):
|
|
list += method(request, **kwargs)
|
|
# now try plugins
|
|
for plugin in plugins.get_plugins():
|
|
if hasattr(plugin, method_name):
|
|
method = getattr(plugin, method_name)
|
|
if callable(method):
|
|
list += method(request, **kwargs)
|
|
|
|
return list
|
|
|
|
|
|
def load_backend(path, kwargs):
|
|
'''Load an IdP backend by its module path'''
|
|
i = path.rfind('.')
|
|
module, attr = path[:i], path[i + 1 :]
|
|
try:
|
|
mod = import_module(module)
|
|
except ImportError as e:
|
|
raise ImproperlyConfigured('Error importing idp backend %s: "%s"' % (module, e))
|
|
except ValueError:
|
|
raise ImproperlyConfigured(
|
|
'Error importing idp backends. Is IDP_BACKENDS a correctly ' 'defined list or tuple?'
|
|
)
|
|
try:
|
|
cls = getattr(mod, attr)
|
|
except AttributeError:
|
|
raise ImproperlyConfigured('Module "%s" does not define a "%s" idp backend' % (module, attr))
|
|
backend_kwargs = {}
|
|
if hasattr(cls, 'id'):
|
|
backend_kwargs.update(kwargs.get(cls.id, {}))
|
|
return cls(**backend_kwargs)
|
|
|
|
|
|
def get_backends(setting_name='IDP_BACKENDS'):
|
|
'''Return the list of enabled cleaned backends.'''
|
|
backends = []
|
|
for backend_path in getattr(app_settings, setting_name):
|
|
kwargs = {}
|
|
if not isinstance(backend_path, six.string_types):
|
|
backend_path, kwargs = backend_path
|
|
kwargs_settings = getattr(app_settings, setting_name + '_KWARGS', {})
|
|
backend = load_backend(backend_path, kwargs_settings)
|
|
# If no enabled method is defined on the backend, backend enabled by default.
|
|
if hasattr(backend, 'enabled') and not backend.enabled():
|
|
continue
|
|
if backend_path in kwargs_settings:
|
|
kwargs.update(kwargs_settings[backend_path])
|
|
# Clean id and name for legacy support
|
|
if hasattr(backend, 'id'):
|
|
if callable(backend.id):
|
|
backend.id = backend.id()
|
|
else:
|
|
backend.id = None
|
|
if hasattr(backend, 'name'):
|
|
if callable(backend.name):
|
|
backend.name = backend.name()
|
|
else:
|
|
backend.name = None
|
|
if not hasattr(backend, 'priority'):
|
|
backend.priority = 999 # backend with undefined priority go last
|
|
if backend.id and backend.id in kwargs_settings:
|
|
kwargs.update(kwargs_settings[backend.id])
|
|
backend.__dict__.update(kwargs)
|
|
backends.append(backend)
|
|
# Order backends list with backend priority
|
|
backends.sort(key=lambda backend: backend.priority)
|
|
return backends
|
|
|
|
|
|
def get_authenticator_method(authenticator, method, parameters):
|
|
if not hasattr(authenticator, method):
|
|
return None
|
|
content = response = getattr(authenticator, method)(**parameters)
|
|
if not response:
|
|
return None
|
|
status_code = 200
|
|
extra_css_class = ''
|
|
# Some authenticator methods return an HttpResponse, others return a string
|
|
if isinstance(response, HttpResponse):
|
|
# Force a TemplateResponse to be rendered.
|
|
if not getattr(response, 'is_rendered', True) and callable(getattr(response, 'render', None)):
|
|
response = response.render()
|
|
content = response.content.decode('utf-8')
|
|
status_code = response.status_code
|
|
if hasattr(response, 'context_data') and response.context_data:
|
|
extra_css_class = response.context_data.get('block-extra-css-class', '')
|
|
return {
|
|
'id': authenticator.id,
|
|
'name': authenticator.name,
|
|
'content': content,
|
|
'response': response,
|
|
'status_code': status_code,
|
|
'authenticator': authenticator,
|
|
'extra_css_class': extra_css_class,
|
|
}
|
|
|
|
|
|
def add_arg(url, key, value=None):
|
|
'''Add a parameter to an URL'''
|
|
key = urlparse.quote(key)
|
|
if value is not None:
|
|
add = '%s=%s' % (key, urlparse.quote(value))
|
|
else:
|
|
add = key
|
|
if '?' in url:
|
|
return '%s&%s' % (url, add)
|
|
else:
|
|
return '%s?%s' % (url, add)
|
|
|
|
|
|
def get_username(user):
|
|
'''Retrieve the username from a user model'''
|
|
if hasattr(user, 'USERNAME_FIELD'):
|
|
return getattr(user, user.USERNAME_FIELD)
|
|
else:
|
|
return user.username
|
|
|
|
|
|
class Service(object):
|
|
url = None
|
|
name = None
|
|
actions = []
|
|
|
|
def __init__(self, **kwargs):
|
|
self.__dict__.update(kwargs)
|
|
|
|
|
|
def field_names(list_of_field_name_and_titles):
|
|
for t in list_of_field_name_and_titles:
|
|
if isinstance(t, six.string_types):
|
|
yield t
|
|
else:
|
|
yield t[0]
|
|
|
|
|
|
def is_valid_url(url):
|
|
try:
|
|
parsed = urlparse.urlparse(url)
|
|
if parsed.scheme in ('http', 'https', ''):
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def make_url(
|
|
to,
|
|
args=(),
|
|
kwargs={},
|
|
keep_params=False,
|
|
params=None,
|
|
append=None,
|
|
request=None,
|
|
include=None,
|
|
exclude=None,
|
|
fragment=None,
|
|
absolute=False,
|
|
resolve=True,
|
|
next_url=None,
|
|
sign_next_url=False,
|
|
):
|
|
"""Build an URL from a relative or absolute path, a model instance, a view
|
|
name or view function.
|
|
|
|
If you pass a request you can ask to keep params from it, exclude some
|
|
of them or include only a subset of them.
|
|
You can set parameters or append to existing one.
|
|
If a parameter value is None, it clears the parameter from the URL, if
|
|
the parameter was appended, it's just ignored.
|
|
"""
|
|
if resolve:
|
|
url = resolve_url(to, *args, **kwargs)
|
|
else:
|
|
url = to
|
|
url = iri_to_uri(url)
|
|
scheme, netloc, path, query_string, o_fragment = urlparse.urlsplit(url)
|
|
url = uri_to_iri(urlparse.urlunsplit((scheme, netloc, path, '', '')))
|
|
fragment = fragment or o_fragment
|
|
# Django < 1.6 compat, query_string is not optional
|
|
url_params = QueryDict(query_string=query_string, mutable=True)
|
|
if keep_params:
|
|
assert request is not None, 'missing request'
|
|
for key, value in request.GET.items():
|
|
if exclude and key in exclude:
|
|
continue
|
|
if include and key not in include:
|
|
continue
|
|
url_params.setlist(key, request.GET.getlist(key))
|
|
if params:
|
|
for key, value in params.items():
|
|
if value is None:
|
|
url_params.pop(key, None)
|
|
elif isinstance(value, (tuple, list)):
|
|
url_params.setlist(key, value)
|
|
else:
|
|
url_params[key] = value
|
|
if next_url:
|
|
url_params[REDIRECT_FIELD_NAME] = next_url
|
|
if sign_next_url:
|
|
url_params[constants.NEXT_URL_SIGNATURE] = crypto.hmac_url(settings.SECRET_KEY, next_url)
|
|
if append:
|
|
for key, value in append.items():
|
|
if value is None:
|
|
continue
|
|
elif isinstance(value, (tuple, list)):
|
|
url_params.extend({key: value})
|
|
else:
|
|
url_params.appendlist(key, value)
|
|
if url_params:
|
|
url += u'?%s' % url_params.urlencode(safe='/')
|
|
if fragment:
|
|
url += u'#%s' % fragment
|
|
if absolute:
|
|
if request:
|
|
url = request.build_absolute_uri(url)
|
|
elif hasattr(settings, 'SITE_BASE_URL'):
|
|
url = urlparse.urljoin(settings.SITE_BASE_URL, url)
|
|
else:
|
|
raise TypeError('make_url() absolute cannot be used without request')
|
|
# keep using unicode
|
|
return url
|
|
|
|
|
|
# improvement over django.shortcuts.redirect
|
|
def redirect(
|
|
request,
|
|
to,
|
|
args=(),
|
|
kwargs={},
|
|
keep_params=False,
|
|
params=None,
|
|
append=None,
|
|
include=None,
|
|
exclude=None,
|
|
permanent=False,
|
|
fragment=None,
|
|
status=302,
|
|
resolve=True,
|
|
):
|
|
"""Build a redirect response to an absolute or relative URL, eventually
|
|
adding params from the request or new, see make_url().
|
|
"""
|
|
url = make_url(
|
|
to,
|
|
args=args,
|
|
kwargs=kwargs,
|
|
keep_params=keep_params,
|
|
params=params,
|
|
append=append,
|
|
request=request,
|
|
include=include,
|
|
exclude=exclude,
|
|
fragment=fragment,
|
|
resolve=resolve,
|
|
)
|
|
if permanent:
|
|
status = 301
|
|
return HttpResponseRedirect(url, status=status)
|
|
|
|
|
|
def redirect_to_login(
|
|
request,
|
|
login_url='auth_login',
|
|
keep_params=True,
|
|
include=(REDIRECT_FIELD_NAME, constants.NONCE_FIELD_NAME),
|
|
**kwargs,
|
|
):
|
|
'''Redirect to the login, eventually adding a nonce'''
|
|
return redirect(request, login_url, keep_params=keep_params, include=include, **kwargs)
|
|
|
|
|
|
def continue_to_next_url(request, keep_params=True, include=(constants.NONCE_FIELD_NAME,), **kwargs):
|
|
next_url = select_next_url(request, settings.LOGIN_REDIRECT_URL, include_post=True)
|
|
return redirect(request, to=next_url, keep_params=keep_params, include=include, **kwargs)
|
|
|
|
|
|
def get_nonce(request):
|
|
nonce = request.GET.get(constants.NONCE_FIELD_NAME)
|
|
if request.method == 'POST':
|
|
nonce = request.POST.get(constants.NONCE_FIELD_NAME, nonce)
|
|
return nonce
|
|
|
|
|
|
def record_authentication_event(request, how, nonce=None):
|
|
"""Record an authentication event in the session and in the database, in
|
|
later version the database persistence can be removed"""
|
|
from .. import models
|
|
|
|
logging.getLogger(__name__).info('logged in (%s)', how)
|
|
authentication_events = request.session.setdefault(constants.AUTHENTICATION_EVENTS_SESSION_KEY, [])
|
|
# As we update a persistent object and not a session key we must
|
|
# explicitly state that the session has been modified
|
|
request.session.modified = True
|
|
event = {
|
|
'who': six.text_type(request.user),
|
|
'who_id': getattr(request.user, 'pk', None),
|
|
'how': how,
|
|
'when': int(time.time()),
|
|
}
|
|
kwargs = {
|
|
'who': six.text_type(request.user)[:80],
|
|
'how': how,
|
|
}
|
|
nonce = nonce or get_nonce(request)
|
|
if nonce:
|
|
kwargs['nonce'] = nonce
|
|
event['nonce'] = nonce
|
|
authentication_events.append(event)
|
|
|
|
models.AuthenticationEvent.objects.create(**kwargs)
|
|
|
|
|
|
def find_authentication_event(request, nonce):
|
|
"""Find an authentication event occurring during this session and matching
|
|
this nonce."""
|
|
for event in get_authentication_events(request=request):
|
|
if event.get('nonce') == nonce:
|
|
return event
|
|
return None
|
|
|
|
|
|
def last_authentication_event(request=None, session=None):
|
|
authentication_events = get_authentication_events(request=request, session=session)
|
|
if authentication_events:
|
|
return authentication_events[-1]
|
|
return None
|
|
|
|
|
|
def login(request, user, how, service=None, service_slug=None, nonce=None, record=True, **kwargs):
|
|
"""Login a user model, record the authentication event and redirect to next
|
|
URL or settings.LOGIN_REDIRECT_URL."""
|
|
from .. import hooks
|
|
from .views import check_cookie_works
|
|
|
|
if service:
|
|
assert service_slug is None
|
|
service_slug = service.slug
|
|
check_cookie_works(request)
|
|
last_login = user.last_login
|
|
auth_login(request, user)
|
|
if hasattr(user, 'init_to_session'):
|
|
user.init_to_session(request.session)
|
|
if constants.LAST_LOGIN_SESSION_KEY not in request.session:
|
|
request.session[constants.LAST_LOGIN_SESSION_KEY] = localize(to_current_timezone(last_login), True)
|
|
record_authentication_event(request, how, nonce=nonce)
|
|
hooks.call_hooks('event', name='login', user=user, how=how, service=service_slug)
|
|
# prevent logint-hint to influence next use of the login page
|
|
if 'login-hint' in request.session:
|
|
del request.session['login-hint']
|
|
if record:
|
|
request.journal.record('user.login', how=how)
|
|
return continue_to_next_url(request, **kwargs)
|
|
|
|
|
|
def login_require(request, next_url=None, login_url='auth_login', service=None, login_hint=(), **kwargs):
|
|
'''Require a login and come back to current URL'''
|
|
|
|
next_url = next_url or request.get_full_path()
|
|
params = kwargs.setdefault('params', {})
|
|
params[REDIRECT_FIELD_NAME] = next_url
|
|
if service:
|
|
set_service_ref(params, service)
|
|
if login_hint:
|
|
request.session['login-hint'] = list(login_hint)
|
|
elif 'login-hint' in request.session:
|
|
# clear previous login-hint if present
|
|
del request.session['login-hint']
|
|
return redirect(request, login_url, **kwargs)
|
|
|
|
|
|
def redirect_to_logout(request, next_url=None, logout_url='auth_logout', **kwargs):
|
|
'''Redirect to the logout and come back to the current page.'''
|
|
next_url = next_url or request.get_full_path()
|
|
params = kwargs.setdefault('params', {})
|
|
params[REDIRECT_FIELD_NAME] = next_url
|
|
return redirect(request, logout_url, **kwargs)
|
|
|
|
|
|
def redirect_and_come_back(request, to, **kwargs):
|
|
'''Redirect to a view adding current URL as next URL parameter'''
|
|
next_url = request.get_full_path()
|
|
params = kwargs.setdefault('params', {})
|
|
params[REDIRECT_FIELD_NAME] = next_url
|
|
return redirect(request, to, **kwargs)
|
|
|
|
|
|
def generate_password():
|
|
"""Generate a password based on a certain composition based on number of
|
|
characters based on classes of characters.
|
|
"""
|
|
composition = ((2, '23456789'), (6, 'ABCDEFGHJKLMNPQRSTUVWXYZ'), (1, '%$/\\#@!'))
|
|
parts = []
|
|
for cnt, alphabet in composition:
|
|
for i in range(cnt):
|
|
parts.append(random.SystemRandom().choice(alphabet))
|
|
random.shuffle(parts, random.SystemRandom().random)
|
|
return ''.join(parts)
|
|
|
|
|
|
def form_add_error(form, msg, safe=False):
|
|
# without this line form._errors is not initialized
|
|
form.errors
|
|
errors = form._errors.setdefault(forms.forms.NON_FIELD_ERRORS, ErrorList())
|
|
if safe:
|
|
msg = html.mark_safe(msg)
|
|
errors.append(msg)
|
|
|
|
|
|
def import_module_or_class(path):
|
|
try:
|
|
return import_module(path)
|
|
except ImportError:
|
|
try:
|
|
module, attr = path.rsplit('.', 1)
|
|
source = import_module(module)
|
|
return getattr(source, attr)
|
|
except (ImportError, AttributeError):
|
|
raise ImproperlyConfigured('unable to import class/module path: %r' % path)
|
|
|
|
|
|
def check_referer(request, skip_post=True):
|
|
"""Check that the current referer match current origin.
|
|
|
|
Post requests are usually ignored as they are already check by the
|
|
CSRF middleware.
|
|
"""
|
|
if skip_post and request.method == 'POST':
|
|
return True
|
|
referer = request.META.get('HTTP_REFERER')
|
|
return referer and same_origin(request.build_absolute_uri(), referer)
|
|
|
|
|
|
def check_session_key(session_key):
|
|
'''Check that a session exists for a given session_key.'''
|
|
from importlib import import_module
|
|
|
|
from django.conf import settings
|
|
|
|
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
|
s = SessionStore(session_key=session_key)
|
|
# If session is empty, it's new
|
|
return s._session != {}
|
|
|
|
|
|
def get_user_from_session_key(session_key):
|
|
'''Get the user logged in an active session'''
|
|
from importlib import import_module
|
|
|
|
from django.conf import settings
|
|
from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, load_backend
|
|
from django.contrib.auth.models import AnonymousUser
|
|
|
|
from authentic2.compat.misc import signature_parameters
|
|
|
|
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
|
session = SessionStore(session_key=session_key)
|
|
try:
|
|
user_id = session[SESSION_KEY]
|
|
backend_path = session[BACKEND_SESSION_KEY]
|
|
assert backend_path in settings.AUTHENTICATION_BACKENDS
|
|
backend = load_backend(backend_path)
|
|
if 'session' in signature_parameters(backend.get_user):
|
|
user = backend.get_user(user_id, session) or AnonymousUser()
|
|
else:
|
|
user = backend.get_user(user_id) or AnonymousUser()
|
|
except (KeyError, AssertionError):
|
|
user = AnonymousUser()
|
|
return user
|
|
|
|
|
|
def to_list(func):
|
|
@wraps(func)
|
|
def f(*args, **kwargs):
|
|
return list(func(*args, **kwargs))
|
|
|
|
return f
|
|
|
|
|
|
def to_iter(func):
|
|
@wraps(func)
|
|
def f(*args, **kwargs):
|
|
return IterableFactory(lambda: func(*args, **kwargs))
|
|
|
|
return f
|
|
|
|
|
|
def normalize_attribute_values(values):
|
|
'''Take a list of values or a single one and normalize it'''
|
|
values_set = set()
|
|
if isinstance(values, six.string_types) or not hasattr(values, '__iter__'):
|
|
values = [values]
|
|
for value in values:
|
|
if isinstance(value, bool):
|
|
value = str(value).lower()
|
|
values_set.add(six.text_type(value))
|
|
return values_set
|
|
|
|
|
|
def attribute_values_to_identifier(values):
|
|
'''Try to find an identifier from attribute values'''
|
|
normalized = normalize_attribute_values(values)
|
|
assert len(normalized) == 1, 'multi-valued attribute cannot be used as an identifier'
|
|
return list(normalized)[0]
|
|
|
|
|
|
def get_hex_uuid():
|
|
return uuid.uuid4().hex
|
|
|
|
|
|
def get_fields_and_labels(*args):
|
|
"""Analyze fields settings and extracts ordered list of fields and
|
|
their overriden labels.
|
|
"""
|
|
labels = {}
|
|
fields = []
|
|
for arg in args:
|
|
for field in arg:
|
|
if isinstance(field, (list, tuple)):
|
|
field, label = field
|
|
labels[field] = label
|
|
if field not in fields:
|
|
fields.append(field)
|
|
return fields, labels
|
|
|
|
|
|
def render_plain_text_template_to_string(template_names, ctx, request=None):
|
|
template = select_template(template_names)
|
|
return template.template.render(make_context(ctx, request=request, autoescape=False))
|
|
|
|
|
|
def send_templated_mail(
|
|
user_or_email,
|
|
template_names,
|
|
context=None,
|
|
with_html=True,
|
|
from_email=None,
|
|
request=None,
|
|
legacy_subject_templates=None,
|
|
legacy_body_templates=None,
|
|
legacy_html_body_templates=None,
|
|
per_ou_templates=False,
|
|
**kwargs,
|
|
):
|
|
"""Send mail to an user by using templates:
|
|
- <template_name>_subject.txt for the subject
|
|
- <template_name>_body.txt for the plain text body
|
|
- <template_name>_body.html for the HTML body
|
|
"""
|
|
from .. import middleware
|
|
|
|
if isinstance(template_names, six.string_types):
|
|
template_names = [template_names]
|
|
if per_ou_templates and getattr(user_or_email, 'ou', None):
|
|
new_template_names = []
|
|
for template in template_names:
|
|
new_template_names.append('_'.join((template, user_or_email.ou.slug)))
|
|
new_template_names.append(template)
|
|
template_names = new_template_names
|
|
if hasattr(user_or_email, 'email'):
|
|
user_or_email = user_or_email.email
|
|
if not request:
|
|
request = middleware.StoreRequestMiddleware().get_request()
|
|
|
|
ctx = copy.copy(app_settings.TEMPLATE_VARS)
|
|
if context:
|
|
ctx.update(context)
|
|
|
|
subject_template_names = [template_name + '_subject.txt' for template_name in template_names]
|
|
subject_template_names += legacy_subject_templates or []
|
|
subject = render_plain_text_template_to_string(subject_template_names, ctx, request=request).strip()
|
|
|
|
body_template_names = [template_name + '_body.txt' for template_name in template_names]
|
|
body_template_names += legacy_body_templates or []
|
|
body = render_plain_text_template_to_string(body_template_names, ctx, request=request)
|
|
|
|
html_body = None
|
|
html_body_template_names = [template_name + '_body.html' for template_name in template_names]
|
|
html_body_template_names += legacy_html_body_templates or []
|
|
if with_html and app_settings.A2_EMAIL_FORMAT != 'text/plain':
|
|
try:
|
|
html_body = render_to_string(html_body_template_names, ctx, request=request)
|
|
except TemplateDoesNotExist:
|
|
html_body = None
|
|
|
|
if app_settings.A2_EMAIL_FORMAT == 'text/html':
|
|
msg = EmailMessage(
|
|
subject, html_body, from_email or settings.DEFAULT_FROM_EMAIL, [user_or_email], **kwargs
|
|
)
|
|
msg.content_subtype = 'html'
|
|
msg.send()
|
|
elif app_settings.A2_EMAIL_FORMAT == 'text/plain':
|
|
msg = EmailMessage(
|
|
subject, body, from_email or settings.DEFAULT_FROM_EMAIL, [user_or_email], **kwargs
|
|
)
|
|
msg.send()
|
|
else:
|
|
send_mail(
|
|
subject,
|
|
body,
|
|
from_email or settings.DEFAULT_FROM_EMAIL,
|
|
[user_or_email],
|
|
html_message=html_body,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def get_fk_model(model, fieldname):
|
|
try:
|
|
field = model._meta.get_field('ou')
|
|
except FieldDoesNotExist:
|
|
return None
|
|
else:
|
|
if not field.is_relation or not field.many_to_one:
|
|
return None
|
|
return field.related_model
|
|
|
|
|
|
def get_registration_url(request, service=None):
|
|
next_url = select_next_url(request, settings.LOGIN_REDIRECT_URL)
|
|
next_url = make_url(
|
|
next_url, request=request, keep_params=True, include=(constants.NONCE_FIELD_NAME,), resolve=False
|
|
)
|
|
params = {REDIRECT_FIELD_NAME: next_url}
|
|
if service:
|
|
set_service_ref(params, service)
|
|
return make_url('registration_register', params=params)
|
|
|
|
|
|
def build_activation_url(request, email, next_url=None, ou=None, **kwargs):
|
|
from authentic2.models import Token
|
|
|
|
data = kwargs.copy()
|
|
data['email'] = email
|
|
if ou:
|
|
data['ou'] = ou.pk
|
|
data[REDIRECT_FIELD_NAME] = next_url
|
|
lifetime = settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24
|
|
# invalidate any token associated with this address
|
|
Token.objects.filter(kind='registration', content__email=email).delete()
|
|
token = Token.create('registration', data, duration=lifetime)
|
|
activate_url = request.build_absolute_uri(
|
|
reverse('registration_activate', kwargs={'registration_token': token.uuid_b64url})
|
|
)
|
|
return activate_url
|
|
|
|
|
|
def build_deletion_url(request, **kwargs):
|
|
data = kwargs.copy()
|
|
data['user_pk'] = request.user.pk
|
|
deletion_token = signing.dumps(data)
|
|
delete_url = request.build_absolute_uri(
|
|
reverse('validate_deletion', kwargs={'deletion_token': deletion_token})
|
|
)
|
|
return delete_url
|
|
|
|
|
|
def send_registration_mail(request, email, ou, template_names=None, next_url=None, context=None, **kwargs):
|
|
"""Send a registration mail to an user. All given kwargs will be used
|
|
to completed the user model.
|
|
|
|
Can raise an smtplib.SMTPException
|
|
"""
|
|
logger = logging.getLogger(__name__)
|
|
User = get_user_model()
|
|
|
|
if not template_names:
|
|
template_names = ['authentic2/activation_email']
|
|
|
|
# registration_url
|
|
registration_url = build_activation_url(request, email=email, next_url=next_url, ou=ou, **kwargs)
|
|
|
|
# existing accounts
|
|
existing_accounts = User.objects.filter(email=email)
|
|
if not app_settings.A2_EMAIL_IS_UNIQUE:
|
|
existing_accounts = existing_accounts.filter(ou=ou, email=email)
|
|
|
|
# ctx for rendering the templates
|
|
context = context or {}
|
|
context.update(
|
|
{
|
|
'registration_url': registration_url,
|
|
'expiration_days': settings.ACCOUNT_ACTIVATION_DAYS,
|
|
'email': email,
|
|
'site': request.get_host(),
|
|
'existing_accounts': existing_accounts,
|
|
}
|
|
)
|
|
|
|
send_templated_mail(
|
|
email,
|
|
template_names,
|
|
request=request,
|
|
context=context,
|
|
# legacy templates, for new templates use
|
|
# authentic2/activation_email_body.txt
|
|
# authentic2/activation_email_body.html
|
|
# authentic2/activation_email_subject.txt
|
|
legacy_subject_templates=['registration/activation_email_subject.txt'],
|
|
legacy_body_templates=['registration/activation_email.txt'],
|
|
legacy_html_body_templates=['registration/activation_email.html'],
|
|
)
|
|
logger.info(u'registration mail sent to %s with registration URL %s...', email, registration_url)
|
|
request.journal.record('user.registration.request', email=email)
|
|
|
|
|
|
def send_account_deletion_code(request, user):
|
|
"""Send an account deletion notification code to a user.
|
|
|
|
Can raise an smtplib.SMTPException
|
|
"""
|
|
logger = logging.getLogger(__name__)
|
|
deletion_url = build_deletion_url(request)
|
|
context = {
|
|
'full_name': request.user.get_full_name(),
|
|
'user': request.user,
|
|
'site': request.get_host(),
|
|
'deletion_url': deletion_url,
|
|
}
|
|
template_names = ['authentic2/account_deletion_code']
|
|
send_templated_mail(user, template_names, context, request=request, per_ou_templates=True)
|
|
logger.info(u'account deletion code sent to %s', user.email)
|
|
|
|
|
|
def send_account_deletion_mail(request, user):
|
|
"""Send an account deletion notification mail to a user.
|
|
|
|
Can raise an smtplib.SMTPException
|
|
"""
|
|
logger = logging.getLogger(__name__)
|
|
context = {'full_name': user.get_full_name(), 'user': user, 'site': request.get_host()}
|
|
template_names = ['authentic2/account_delete_notification']
|
|
send_templated_mail(user, template_names, context, request=request, per_ou_templates=True)
|
|
logger.info(u'account deletion mail sent to %s', user.email)
|
|
|
|
|
|
def build_reset_password_url(user, request=None, next_url=None, set_random_password=True, sign_next_url=True):
|
|
'''Build a reset password URL'''
|
|
from authentic2.models import Token
|
|
|
|
if set_random_password:
|
|
user.set_password(uuid.uuid4().hex)
|
|
user.save()
|
|
lifetime = settings.PASSWORD_RESET_TIMEOUT_DAYS * 3600 * 24
|
|
# invalidate any token associated with this user
|
|
Token.objects.filter(kind='pw-reset', content__user=user.pk, content__email=user.email).delete()
|
|
token = Token.create('pw-reset', {'user': user.pk, 'email': user.email}, duration=lifetime)
|
|
reset_url = make_url(
|
|
'password_reset_confirm',
|
|
kwargs={'token': token.uuid_b64url},
|
|
next_url=next_url,
|
|
sign_next_url=sign_next_url,
|
|
request=request,
|
|
absolute=True,
|
|
)
|
|
return reset_url, token
|
|
|
|
|
|
def send_password_reset_mail(
|
|
user,
|
|
template_names=None,
|
|
request=None,
|
|
token_generator=None,
|
|
from_email=None,
|
|
next_url=None,
|
|
context=None,
|
|
legacy_subject_templates=['registration/password_reset_subject.txt'],
|
|
legacy_body_templates=['registration/password_reset_email.html'],
|
|
set_random_password=True,
|
|
sign_next_url=True,
|
|
**kwargs,
|
|
):
|
|
from authentic2.journal import journal
|
|
|
|
from .. import middleware
|
|
|
|
if not user.email:
|
|
raise ValueError('user must have an email')
|
|
logger = logging.getLogger(__name__)
|
|
if not template_names:
|
|
template_names = 'authentic2/password_reset_email'
|
|
if not request:
|
|
request = middleware.StoreRequestMiddleware().get_request()
|
|
|
|
ctx = {}
|
|
ctx.update(context or {})
|
|
ctx.update(
|
|
{
|
|
'user': user,
|
|
'email': user.email,
|
|
'expiration_days': settings.PASSWORD_RESET_TIMEOUT_DAYS,
|
|
'site': request.get_host() if request else '',
|
|
}
|
|
)
|
|
|
|
# Build reset URL
|
|
ctx['reset_url'], token = build_reset_password_url(
|
|
user,
|
|
request=request,
|
|
next_url=next_url,
|
|
set_random_password=set_random_password,
|
|
sign_next_url=sign_next_url,
|
|
)
|
|
|
|
send_templated_mail(
|
|
user,
|
|
template_names,
|
|
ctx,
|
|
request=request,
|
|
legacy_subject_templates=legacy_subject_templates,
|
|
legacy_body_templates=legacy_body_templates,
|
|
per_ou_templates=True,
|
|
**kwargs,
|
|
)
|
|
logger.info(
|
|
u'password reset request for user %s, email sent to %s ' 'with token %s', user, user.email, token.uuid
|
|
)
|
|
journal.record('user.password.reset.request', email=user.email, user=user)
|
|
|
|
|
|
def batch(iterable, size):
|
|
"""Batch an iterable as an iterable of iterables of at most size element
|
|
long.
|
|
"""
|
|
sourceiter = iter(iterable)
|
|
while True:
|
|
batchiter = islice(sourceiter, size)
|
|
# call next() at least one time to advance, if the caller does not
|
|
# consume the returned iterators, sourceiter will never be exhausted.
|
|
yield chain([batchiter.next()], batchiter)
|
|
|
|
|
|
def batch_queryset(qs, size=1000):
|
|
"""Batch prefetched potentially very large queryset, it's a middle ground
|
|
between using .iterator() which cannot be prefetched and prefetching a full
|
|
table, which can take a larte place in memory.
|
|
"""
|
|
for i in count(0):
|
|
chunk = qs[i * size : (i + 1) * size]
|
|
if not chunk:
|
|
break
|
|
for row in chunk:
|
|
yield row
|
|
|
|
|
|
def lower_keys(d):
|
|
'''Convert all keys in dictionary d to lowercase'''
|
|
return dict((key.lower(), value) for key, value in d.items())
|
|
|
|
|
|
def to_dict_of_set(d):
|
|
'''Convert a dictionary of sequence into a dictionary of sets'''
|
|
return dict((k, set(v)) for k, v in d.items())
|
|
|
|
|
|
def datetime_to_utc(dt):
|
|
if timezone.is_naive(dt):
|
|
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
|
|
def datetime_to_xs_datetime(dt):
|
|
return datetime_to_utc(dt).isoformat().split('.')[0] + 'Z'
|
|
|
|
|
|
def good_next_url(request, next_url):
|
|
'''Check if an URL is a good next_url'''
|
|
from .. import hooks
|
|
|
|
if not next_url:
|
|
return False
|
|
if next_url.startswith('/') and (len(next_url) == 1 or next_url[1] != '/'):
|
|
return True
|
|
if same_origin(request.build_absolute_uri(), next_url):
|
|
return True
|
|
signature = request.POST.get(constants.NEXT_URL_SIGNATURE) or request.GET.get(
|
|
constants.NEXT_URL_SIGNATURE
|
|
)
|
|
|
|
if signature:
|
|
return crypto.check_hmac_url(settings.SECRET_KEY, next_url, signature)
|
|
|
|
for origin in app_settings.A2_REDIRECT_WHITELIST:
|
|
if same_origin(next_url, origin):
|
|
return True
|
|
if app_settings.A2_REGISTRATION_REDIRECT:
|
|
origin = app_settings.A2_REGISTRATION_REDIRECT
|
|
if isinstance(origin, (tuple, list)):
|
|
origin = origin[0]
|
|
if same_origin(next_url, origin):
|
|
return True
|
|
result = hooks.call_hooks_first_result('good_next_url', next_url)
|
|
if result is not None:
|
|
return result
|
|
return False
|
|
|
|
|
|
def is_ascii(something):
|
|
try:
|
|
something.encode('ascii')
|
|
return True
|
|
except UnicodeEncodeError:
|
|
return False
|
|
|
|
|
|
def get_next_url(params, field_name=None):
|
|
'''Extract and decode a next_url field'''
|
|
field_name = field_name or REDIRECT_FIELD_NAME
|
|
next_url = params.get(field_name)
|
|
if not next_url:
|
|
return None
|
|
if not is_ascii(next_url) or not is_valid_url(next_url):
|
|
return None
|
|
return next_url
|
|
|
|
|
|
def select_next_url(request, default, field_name=None, include_post=False, replace=None):
|
|
'''Select the first valid next URL'''
|
|
next_url = (include_post and get_next_url(request.POST, field_name=field_name)) or get_next_url(
|
|
request.GET, field_name=field_name
|
|
)
|
|
if good_next_url(request, next_url):
|
|
if replace:
|
|
for key, value in replace.items():
|
|
next_url = next_url.replace(key, urlparse.quote(value))
|
|
return next_url
|
|
return default
|
|
|
|
|
|
def human_duration(seconds):
|
|
day = 24 * 3600
|
|
hour = 3600
|
|
minute = 60
|
|
days, seconds = seconds // day, seconds % day
|
|
hours, seconds = seconds // hour, seconds % hour
|
|
minutes, seconds = seconds // minute, seconds % minute
|
|
|
|
s = []
|
|
if days:
|
|
s.append(ungettext('%s day', '%s days', days) % days)
|
|
if hours:
|
|
s.append(ungettext('%s hour', '%s hours', hours) % hours)
|
|
if minutes:
|
|
s.append(ungettext('%s minute', '%s minutes', minutes) % minutes)
|
|
if seconds:
|
|
s.append(ungettext('%s second', '%s seconds', seconds) % seconds)
|
|
return ', '.join(s)
|
|
|
|
|
|
class ServiceAccessDenied(Exception):
|
|
def __init__(self, service):
|
|
self.service = service
|
|
|
|
|
|
def unauthorized_view(request, service):
|
|
context = {'callback_url': service.unauthorized_url or reverse('auth_homepage')}
|
|
return render(request, 'authentic2/unauthorized.html', context=context)
|
|
|
|
|
|
PROTOCOLS_TO_PORT = {
|
|
'http': '80',
|
|
'https': '443',
|
|
}
|
|
|
|
|
|
def netloc_to_host_port(netloc):
|
|
if not netloc:
|
|
return None, None
|
|
splitted = netloc.split(':', 1)
|
|
if len(splitted) > 1:
|
|
return splitted[0], splitted[1]
|
|
return splitted[0], None
|
|
|
|
|
|
def same_domain(domain1, domain2):
|
|
if domain1 == domain2:
|
|
return True
|
|
|
|
if not domain1 or not domain2:
|
|
return False
|
|
|
|
if domain2.startswith('.'):
|
|
# p1 is a sub-domain or the base domain
|
|
if domain1.endswith(domain2) or domain1 == domain2[1:]:
|
|
return True
|
|
return False
|
|
|
|
|
|
def same_origin(url1, url2):
|
|
"""Checks if both URL use the same domain. It understands domain patterns on url2, i.e. .example.com
|
|
matches www.example.com.
|
|
|
|
If not scheme is given in url2, scheme compare is skipped.
|
|
If not scheme and not port are given, port compare is skipped.
|
|
The last two rules allow authorizing complete domains easily.
|
|
"""
|
|
p1, p2 = urlparse.urlparse(url1), urlparse.urlparse(url2)
|
|
p1_host, p1_port = netloc_to_host_port(p1.netloc)
|
|
p2_host, p2_port = netloc_to_host_port(p2.netloc)
|
|
|
|
if p2.scheme and p1.scheme != p2.scheme:
|
|
return False
|
|
|
|
if not same_domain(p1_host, p2_host):
|
|
return False
|
|
|
|
try:
|
|
if (p2_port or (p1_port and p2.scheme)) and (
|
|
(p1_port or PROTOCOLS_TO_PORT[p1.scheme]) != (p2_port or PROTOCOLS_TO_PORT[p2.scheme])
|
|
):
|
|
return False
|
|
except (ValueError, KeyError):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def simulate_authentication(
|
|
request, user, method, backend='authentic2.backends.models_backend.ModelBackend', service=None, **kwargs
|
|
):
|
|
'''Simulate a normal login by forcing a backend attribute on the user instance'''
|
|
# do not modify the passed user
|
|
user = copy.deepcopy(user)
|
|
user.backend = backend
|
|
return login(request, user, method, service=service, record=False, **kwargs)
|
|
|
|
|
|
def get_manager_login_url():
|
|
from authentic2.manager import app_settings
|
|
|
|
return app_settings.LOGIN_URL or settings.LOGIN_URL
|
|
|
|
|
|
def send_email_change_email(user, email, request=None, context=None, template_names=None):
|
|
'''Send an email to verify that user can take email as its new email'''
|
|
assert user
|
|
assert email
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
if template_names is None:
|
|
template_names = ['authentic2/change_email_notification']
|
|
legacy_subject_templates = ['profiles/email_change_subject.txt']
|
|
legacy_body_templates = ['profiles/email_change_body.txt']
|
|
else:
|
|
legacy_subject_templates = None
|
|
legacy_body_templates = None
|
|
|
|
# build verify email URL containing a signed token
|
|
token = signing.dumps(
|
|
{
|
|
'email': email,
|
|
'user_pk': user.pk,
|
|
}
|
|
)
|
|
link = '{0}?token={1}'.format(reverse('email-change-verify'), token)
|
|
link = request.build_absolute_uri(link)
|
|
|
|
# check if email should be unique and is not
|
|
email_is_not_unique = False
|
|
qs = get_user_model().objects.all()
|
|
if app_settings.A2_EMAIL_IS_UNIQUE:
|
|
email_is_not_unique = qs.filter(email=email).exclude(pk=user.pk).exists()
|
|
elif user.ou and user.ou.email_is_unique:
|
|
email_is_not_unique = qs.filter(email=email, ou=user.ou).exclude(pk=user.pk).exists()
|
|
ctx = context or {}
|
|
ctx.update(
|
|
{
|
|
'email': email,
|
|
'old_email': user.email,
|
|
'user': user,
|
|
'link': link,
|
|
'site': request.get_host(),
|
|
'domain': request.get_host(),
|
|
'token_lifetime': human_duration(app_settings.A2_EMAIL_CHANGE_TOKEN_LIFETIME),
|
|
'password_reset_url': request.build_absolute_uri(reverse('password_reset')),
|
|
'email_is_not_unique': email_is_not_unique,
|
|
}
|
|
)
|
|
logger.info(u'sent email verify email to %s for %s', email, user)
|
|
send_templated_mail(
|
|
email,
|
|
template_names,
|
|
context=ctx,
|
|
legacy_subject_templates=legacy_subject_templates,
|
|
legacy_body_templates=legacy_body_templates,
|
|
)
|
|
|
|
|
|
def get_user_flag(user, name, default=None):
|
|
'''Get a boolean flag settable at user, by a hook, globally or ou wide'''
|
|
from .. import hooks
|
|
|
|
setting_value = getattr(app_settings, 'A2_USER_' + name.upper(), None)
|
|
if setting_value is not None:
|
|
return bool(setting_value)
|
|
|
|
user_value = getattr(user, name, None)
|
|
if user_value is not None:
|
|
return user_value
|
|
|
|
hook_value = hooks.call_hooks_first_result('user_' + name, user=user)
|
|
if hook_value is not None:
|
|
return bool(hook_value)
|
|
|
|
if user.ou and hasattr(user.ou, 'user_' + name):
|
|
ou_value = getattr(user.ou, 'user_' + name, None)
|
|
if ou_value is not None:
|
|
return ou_value
|
|
return default
|
|
|
|
|
|
def user_can_change_password(user=None, request=None):
|
|
from .. import hooks
|
|
|
|
if not app_settings.A2_REGISTRATION_CAN_CHANGE_PASSWORD:
|
|
return False
|
|
if request is not None and user is None and hasattr(request, 'user'):
|
|
user = request.user
|
|
if user is not None and hasattr(user, 'can_change_password') and user.can_change_password() is False:
|
|
return False
|
|
for can in hooks.call_hooks('user_can_change_password', user=user, request=request):
|
|
if can is False:
|
|
return can
|
|
return True
|
|
|
|
|
|
def get_authentication_events(request=None, session=None):
|
|
if request is not None and session is None:
|
|
session = getattr(request, 'session', None)
|
|
if session is not None:
|
|
return session.get(constants.AUTHENTICATION_EVENTS_SESSION_KEY, [])
|
|
return []
|
|
|
|
|
|
def gettid():
|
|
"""Returns OS thread id - Specific to Linux"""
|
|
libc = ctypes.cdll.LoadLibrary('libc.so.6')
|
|
SYS_gettid = 186
|
|
return libc.syscall(SYS_gettid)
|
|
|
|
|
|
def authenticate(request=None, **kwargs):
|
|
# Compatibility layer with Django 1.8
|
|
return dj_authenticate(request=request, **kwargs)
|
|
|
|
|
|
def get_remember_cookie(request, name, count=5):
|
|
value = request.COOKIES.get(name)
|
|
if not value:
|
|
return []
|
|
try:
|
|
parsed = value.split()
|
|
except Exception:
|
|
return []
|
|
|
|
values = []
|
|
for i, v in zip(range(count), parsed):
|
|
try:
|
|
values.append(int(v))
|
|
except ValueError:
|
|
return []
|
|
return values
|
|
|
|
|
|
def prepend_remember_cookie(request, response, name, value, count=5):
|
|
values = get_remember_cookie(request, name, count=count)
|
|
values = [value] + values[: count - 1]
|
|
response.set_cookie(
|
|
name,
|
|
' '.join(str(value) for value in values),
|
|
max_age=86400 * 365, # keep preferences for 1 year
|
|
path=request.path,
|
|
httponly=True,
|
|
)
|