authentic/authentic2/saml/common.py

717 lines
27 KiB
Python

import urlparse
import os.path
import urllib
import urllib2
import httplib
import logging
import re
import datetime
import lasso
from django.template import RequestContext
from django.conf import settings
from django.http import HttpResponseRedirect, Http404, HttpResponse
from django.shortcuts import render_to_response
from django.utils.translation import ugettext as _
from django.db import transaction
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist
from authentic2.saml.models import LibertyFederation, LibertyProvider, \
LibertyManageDump, LibertySessionDump, LibertyServiceProvider, \
LibertyIdentityProvider, LibertySessionSP, IdPOptionsSPPolicy, \
SPOptionsIdPPolicy, \
AuthorizationSPPolicy, AuthorizationAttributeMapping, \
LIBERTY_SESSION_DUMP_KIND_IDP
from authentic2.saml import models
from authentic2.saml import saml2utils
from authentic2.http_utils import get_url
from .. import nonce
AUTHENTIC_STATUS_CODE_NS = "http://authentic.entrouvert.org/status_code/"
AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER = AUTHENTIC_STATUS_CODE_NS + \
"UnknownProvider"
AUTHENTIC_STATUS_CODE_MISSING_NAMEID= AUTHENTIC_STATUS_CODE_NS + \
"MissingNameID"
AUTHENTIC_STATUS_CODE_MISSING_SESSION_INDEX = AUTHENTIC_STATUS_CODE_NS + \
"MissingSessionIndex"
AUTHENTIC_STATUS_CODE_UNKNOWN_SESSION = AUTHENTIC_STATUS_CODE_NS + \
"UnknownSession"
AUTHENTIC_STATUS_CODE_MISSING_DESTINATION = AUTHENTIC_STATUS_CODE_NS + \
"MissingDestination"
AUTHENTIC_STATUS_CODE_INTERNAL_SERVER_ERROR = AUTHENTIC_STATUS_CODE_NS + \
"InternalServerError"
AUTHENTIC_STATUS_CODE_UNAUTHORIZED = AUTHENTIC_STATUS_CODE_NS + \
"Unauthorized"
logger = logging.getLogger('authentic2.saml.common')
# timeout for messages and assertions issue instant
NONCE_TIMEOUT = getattr(settings, 'SAML2_NONCE_TIMEOUT',
getattr(settings, 'NONCE_TIMEOUT', 30))
# do we check id on SAML2 messages ?
CHECKS_ID = getattr(settings, 'SAML2_CHECKS_ID', True)
def get_soap_message(request, on_error_raise = True):
'''Verify that POST content looks like a SOAP message and returns it'''
if request.method != 'POST' or \
'text/xml' not in request.META['CONTENT_TYPE']:
if on_error_raise:
raise Http404('Only SOAP messages here')
else:
return None
return request.raw_post_data
def get_http_binding(request):
if request.method == 'GET':
return 'GET'
elif request.method == 'POST':
# disambiguate SOAP and form POST
if request.META['CONTENT_TYPE'] in \
('application/x-www-form-urlencoded', 'multipart/form-data'):
return 'POST'
else:
return 'SOAP'
# SAMLv2 methods
def get_base_path(request, metadata):
'''Get endpoints base path given metadata path
'''
path = urlparse.urlparse(metadata).path
return request.build_absolute_uri(os.path.dirname(path))
def get_entity_id(request, metadata):
'''Return the EntityID, given metadata absolute path
'''
return request.build_absolute_uri(metadata)
asynchronous_bindings = [ lasso.SAML2_METADATA_BINDING_REDIRECT,
lasso.SAML2_METADATA_BINDING_POST ]
soap_bindings = [ lasso.SAML2_METADATA_BINDING_SOAP ]
all_bindings = asynchronous_bindings + [ lasso.SAML2_METADATA_BINDING_SOAP ]
def get_saml2_metadata(request, metadata, idp_map=None, sp_map=None, options={}):
metagen = saml2utils.Saml2Metadata(get_entity_id(request, metadata),
url_prefix = get_base_path(request, metadata))
if idp_map:
metagen.add_idp_descriptor(idp_map, options)
if sp_map:
metagen.add_sp_descriptor(sp_map, options)
return str(metagen)
def create_saml2_server(request, metadata, idp_map=None, sp_map=None, options={}):
'''Create a lasso Server object for using with a profile'''
server = lasso.Server.newFromBuffers(get_saml2_metadata(request, metadata,
idp_map=idp_map, sp_map=sp_map, options=options),
settings.SAML_SIGNATURE_PRIVATE_KEY)
if not server:
raise Exception('Cannot create LassoServer object')
return server
def get_saml2_post_response(request):
'''Extract the SAMLRequest field from the POST'''
return request.POST.get(lasso.SAML2_FIELD_RESPONSE, '')
def get_saml2_post_request(request):
'''Extract the SAMLRequest field from the POST'''
return request.POST.get(lasso.SAML2_FIELD_REQUEST, '')
def get_saml2_query_request(request):
return request.META.get('QUERY_STRING', '')
def get_saml2_soap_request(request):
return get_soap_message(request)
def get_saml2_request_message_async_binding(request):
'''Return SAMLv2 message whatever the HTTP binding used'''
binding = get_http_binding(request)
if binding == 'GET':
return get_saml2_query_request(request)
elif binding == 'POST':
return get_saml2_post_request(request)
else:
raise Http404('This endpoint is only for asynchornous bindings')
def get_saml2_request_message(request):
'''Return SAMLv2 message whatever the HTTP binding used'''
binding = get_http_binding(request)
if binding == 'GET':
return get_saml2_query_request(request)
elif binding == 'POST':
return get_saml2_post_request(request)
elif binding == 'SOAP':
return get_saml2_soap_request(request)
def return_saml2_response(request, profile, title = ''):
'''Finish your SAMLv2 views with this method to return a SAML
response'''
return return_saml2(request, profile, lasso.SAML2_FIELD_RESPONSE, title)
def return_saml2_request(request, profile, title = ''):
'''Finish your SAMLv2 views with this method to return a SAML
request'''
return return_saml2(request, profile, lasso.SAML2_FIELD_REQUEST, title)
def return_saml2(request, profile, field_name, title = ''):
'''Helper to handle SAMLv2 bindings to emit request and responses'''
context_instance = RequestContext(request)
if profile.msgBody:
if profile.msgUrl:
return render_to_response('saml/post_form.html',{
'title': title,
'url': profile.msgUrl,
'fieldname': field_name,
'body': profile.msgBody,
'relay_state': profile.msgRelayState },
context_instance=context_instance)
return HttpResponse(profile.msgBody, mimetype = 'text/xml')
elif profile.msgUrl:
return HttpResponseRedirect(profile.msgUrl)
else:
raise TypeError('profile do not contain a response')
def check_id_and_issue_instant(request_response_or_assertion, now=None):
'''
Check that issue instant is not older than a timeout and also checks
that the id has never been seen before.
Nonce are cached for two times the relative timeout length of the issue
instant.
'''
if now is None:
now = datetime.datetime.utcnow()
try:
issue_instant = request_response_or_assertion.issueInstant
issue_instant = saml2utils.iso8601_to_datetime(issue_instant)
delta = datetime.timedelta(seconds=NONCE_TIMEOUT)
if not (now - delta <= issue_instant < now + delta):
logger.warning('IssueInstant %s not in the interval [%s, %s[',
issue_instant, now-delta, now+delta)
return False
except ValueError:
logger.error('Unable to parse an IssueInstant: %r', issue_instant)
return False
if CHECKS_ID:
_id = request_response_or_assertion.id
if _id is None:
logger.warning('missing ID')
return False
if not nonce.accept_nonce(_id, 'SAML', 2*NONCE_TIMEOUT):
logger.warning("ID '%r' already used, request/response/assertion "
"refused", _id)
return False
return True
def return_saml_soap_response(profile):
return HttpResponse(profile.msgBody, mimetype = 'text/xml')
# Helper method to handle profiles endpoints
# In the future we should move away from monolithic object (LassoIdentity and
# LassoSession) holding all the datas, to manipulate them at row Level with
# LibertyFederation and LibertyAssertion objects.
START_IDENTITY_DUMP = '''<Identity xmlns="http://www.entrouvert.org/namespaces/lasso/0.0" Version="2">
'''
MIDDLE_IDENTITY_DUMP = '''<lasso:Federation xmlns:lasso="http://www.entrouvert.org/namespaces/lasso/0.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" RemoteProviderID="{sp_id}" FederationDumpVersion="2">
<lasso:LocalNameIdentifier>
<saml:NameID Format="{format}" {qualifiers}>{content}</saml:NameID>
</lasso:LocalNameIdentifier>
</lasso:Federation>
'''
END_IDENTITY_DUMP = '''</Identity>'''
def federations_to_identity_dump(self_entity_id, federations):
l = [ START_IDENTITY_DUMP ]
for federation in federations:
if federation.sp:
sp_id = federation.sp.liberty_provider.entity_id
elif federation.idp:
sp_id = self_entity_id
qualifiers = []
if federation.name_id_qualifier:
qualifiers.append('NameQualifier="%s"' % federation.name_id_qualifier)
if federation.name_id_sp_name_qualifier:
qualifiers.append('SPNameQualifier="%s"' % federation.name_id_sp_name_qualifier)
l.append(MIDDLE_IDENTITY_DUMP.format(
content=federation.name_id_content,
format=federation.name_id_format,
sp_id=sp_id,
qualifiers=' '.join(qualifiers)))
l.append(END_IDENTITY_DUMP)
return ''.join(l)
def load_federation(request, entity_id, login, user = None):
'''Load an identity dump from the database'''
if not user:
user = request.user
assert user is not None
identity_dump = federations_to_identity_dump(entity_id,
LibertyFederation.objects.filter(user=user))
login.setIdentityFromDump(identity_dump)
def load_session(request, login, session_key = None,
kind=LIBERTY_SESSION_DUMP_KIND_IDP):
'''Load a session dump from the database'''
if not session_key:
session_key = request.session.session_key
try:
q = LibertySessionDump.objects.get(django_session_key=session_key,
kind=kind)
logger.debug('load_session: session dump found %s' %q.session_dump.encode('utf8'))
login.setSessionFromDump(q.session_dump.encode('utf8'))
logger.debug('load_session: set session from dump done %s' %login.session.dump())
except ObjectDoesNotExist:
pass
def save_session(request, login, session_key=None,
kind=LIBERTY_SESSION_DUMP_KIND_IDP):
'''Save session dump to database'''
if not session_key:
session_key = request.session.session_key
if login.isSessionDirty:
q, creation = LibertySessionDump.objects.get_or_create(
django_session_key=session_key, kind=kind)
if login.session:
q.session_dump = login.session.dump()
else:
q.session_dump = None
q.save()
def delete_session(request, session_key=None):
'''Delete all liberty sessions for a django session'''
if not session_key:
session_key = request.session.session_key
try:
LibertySessionDump.objects.\
filter(django_session_key = session_key).delete()
except Exception, e:
logger.error('delete_session: Exception %s' % str(e))
def save_manage(request, manage):
if not request or not manage:
raise Exception('Cannot save manage dump')
LibertyManageDump(django_session_key = request.session.session_key,
manage_dump = manage.dump()).save()
def get_manage_dump(request):
if not request:
raise Exception('Cannot get manage dump')
d = LibertyManageDump.objects.filter(django_session_key = request.session.session_key)
return d
@transaction.commit_on_success
def retrieve_metadata_and_create(request, provider_id, sp_or_idp):
logger.debug('trying to load %s from wkl' % provider_id)
if not provider_id.startswith('http'):
logger.debug('not an http url, failing')
return None
# Try the WKL
try:
metadata = get_url(provider_id)
except Exception, e:
logging.error('SAML metadata autoload: failure to retrieve metadata '
'for entity id %r: %s' % (provider_id, e))
return None
logger.debug('loaded %d bytes' % len(metadata))
try:
metadata = unicode(metadata, 'utf8')
except:
logging.error('SAML metadata autoload: retrieved metadata \
for entity id %r is not UTF-8' % provider_id)
return None
p = LibertyProvider(metadata=metadata)
try:
p.full_clean(exclude=['entity_id','protocol_conformance'])
except ValidationError, e:
logging.error('SAML metadata autoload: retrieved metadata \
for entity id %r are invalid, %s' % (provider_id, e.args))
return None
except:
logging.exception('SAML metadata autoload: retrieved metadata validation raised an unknown exception')
return None
p.save()
logger.debug('%s saved' % p)
if sp_or_idp == 'sp':
s = LibertyServiceProvider(liberty_provider=p, enabled=True)
s.save()
elif sp_or_idp == 'idp':
s = LibertyIdentityProvider(liberty_provider=p, enabled=True)
s.save()
return p
def load_provider(request, provider_id, server=None, sp_or_idp='sp',
autoload=False):
'''Look up a provider in the database, and verify it handles wanted
role be it sp or idp.
Arguments:
request -- the currently handled request
provider_id -- the entity ID of the searched provider
Keyword arguments:
server -- a lasso.Server object into which to load the given provider
sp_or_idp -- kind of the provider we are looking for, can be 'sp' or 'idp',
default to 'sp'
'''
try:
liberty_provider = LibertyProvider.objects.get(entity_id=provider_id)
except LibertyProvider.DoesNotExist:
autoload = getattr(settings, 'SAML_METADATA_AUTOLOAD', 'none')
if autoload and (autoload == 'sp' or autoload == 'both'):
liberty_provider = retrieve_metadata_and_create(request, provider_id, sp_or_idp)
if not liberty_provider:
return False
else:
return False
if sp_or_idp == 'sp':
try:
service_provider = liberty_provider.service_provider
except LibertyServiceProvider.DoesNotExist:
return False
if not service_provider.enabled:
return False
if server:
server.addProviderFromBuffer(lasso.PROVIDER_ROLE_SP,
liberty_provider.metadata.encode('utf8'))
elif sp_or_idp == 'idp':
try:
identity_provider = liberty_provider.identity_provider
except LibertyIdentityProvider.DoesNotExist:
return False
if not identity_provider.enabled:
return False
if server:
server.addProviderFromBuffer(lasso.PROVIDER_ROLE_IDP,
liberty_provider.metadata.encode('utf8'))
else:
raise Exception('unsupported option sp_or_idp = %r' % sp_or_idp)
return liberty_provider
# Federation management
def add_federation(user, login=None, name_id=None, provider_id=None):
if not name_id:
if not (login and login.nameIdentifier):
return None
name_id=login.nameIdentifier
kwargs = models.nameid2kwargs(name_id)
if provider_id:
kwargs['idp'] = LibertyProvider.objects.get(entity_id=provider_id).identity_provider
fed = LibertyFederation(user=user, **kwargs)
fed.save()
return fed
def lookup_federation_by_name_identifier(name_id=None, profile=None):
'''Try to find a LibertyFederation object for the given NameID or
profile object.'''
if not name_id:
name_id = profile.nameIdentifier
kwargs = models.nameid2kwargs(name_id)
try:
return LibertyFederation.objects.get(**kwargs)
except:
return None
def lookup_federation_by_name_id_and_provider_id(name_id, provider_id):
'''Try to find a LibertyFederation object for the given NameID and
the provider id.'''
kwargs = models.nameid2kwargs(name_id)
kwargs['idp'] = LibertyProvider.objects.get(entity_id=provider_id).identity_provider
try:
return LibertyFederation.objects.get(user__isnull=False, **kwargs)
except:
return None
# TODO: Does it happen that a user have multiple federation with a same idp? NO
def lookup_federation_by_user(user, qualifier):
if not user or not qualifier:
return None
fed = LibertyFederation.objects.filter(user=user, name_id_qualifier=qualifier)
if fed and fed.count()>1:
# TODO: delete all but the last record
raise Exception('Unconsistent federation record for %s' % qualifier)
if not fed:
return None
return fed[0]
# The session_index is the "session on the IdP" identifiers
# One identifier is dedicated for each sp for each user session
# to not be a factor of linkability between sp
# (like the nameId dedicated for each sp)
# A same user IdP session is thus made of as many session index as SP having received an auth a8n
# The session index is only useful to maintain
# the coherence between the sessions on the IdP and on the SP
# for the global logout:
# If one session is broken somewhere, when a session is restablished there
# it can be linked to other remaining session
# and then make feasible the global logout
# The table entry for the liberty session should be removed at the logout
# TODO: Xpath search of sessionIndex
def maintain_liberty_session_on_service_provider(request, login):
if not login:
return False
# 1. Retrieve this user federation
fed = lookup_federation_by_name_identifier(profile=login)
if not fed:
return False
# 2.a Retrieve a liberty session with the session index and this federation
try:
s = LibertySessionSP.objects.get(federation=fed, session_index=login.response.assertion[0].authnStatement[0].sessionIndex)
# Liberty Session already existing
# If the local session registered is different: updated
# It would mean that the local session was broken
# and not the distant one
s.django_session_key = request.session.session_key
s.save()
return True
except:
pass
# 2.b Retrieve a liberty session with the django session identifier and this federation
try:
s = LibertySessionSP.objects.get(federation=fed, django_session_key=request.session.session_key)
# Local session already existing
# If the index session registered is different: updated
# It would mean that the distant session was broken
# and not the local one
s.session_index = login.response.assertion[0].authnStatement[0].sessionIndex
s.save()
return True
except:
pass
# 2.c Create a new Liberty Session
try:
s = LibertySessionSP()
s.federation = fed
s.django_session_key = request.session.session_key
s.session_index = login.session.getAssertions()[0].authnStatement[0].sessionIndex
s.save()
return True
except:
return False
return False
def get_session_index(request):
if not request:
return None
try:
s = LibertySessionSP.objects.get(django_session_key=request.session.session_key)
return s.session_index
except:
return None
def remove_liberty_session_sp(request, session_key=None):
if not session_key:
session_key = request.session.session_key
try:
LibertySessionSP.objects.\
filter(django_session_key=session_key).delete()
except Exception, e:
logger.error('remove_liberty_session_sp: Exception %s' % str(e))
def get_provider_of_active_session(request):
if not request:
return None
try:
s = LibertySessionSP.objects.get(django_session_key=request.session.session_key)
p = s.federation.idp.liberty_provider
return p
except:
return None
def get_provider_of_active_session_name(request):
if not request:
return None
p = get_provider_of_active_session(request)
if not p:
return None
from urlparse import urlparse
return urlparse(p.entity_id)[1]
class SOAPException(Exception):
url = None
def __init__(self, url):
self.url = url
def soap_call(url, msg, client_cert = None):
if not client_cert:
request = urllib2.Request(url, data=msg,
headers={'Content-Type': 'text/xml'})
return urllib2.urlopen(request).read()
if url.startswith('http://'):
host, query = urllib.splithost(url[5:])
conn = httplib.HTTPConnection(host)
else:
host, query = urllib.splithost(url[6:])
conn = httplib.HTTPSConnection(host,
key_file = client_cert, cert_file = client_cert)
logger.debug('soap_call: host %s' % host)
logger.debug('soap_call: query %s' % query)
logger.debug('soap_call: msg %s' % msg)
try:
conn.request('POST', query, msg, {'Content-Type': 'text/xml'})
response = conn.getresponse()
except Exception, err:
logging.error('SOAP error (on %s): %s' % (url, err))
raise SOAPException(url)
logger.debug('soap_call: response %s' % str(response))
try:
data = response.read()
except Exception, err:
logging.error('SOAP error (on %s): %s' % (url, err))
raise SOAPException(url)
logger.debug('soap_call: data %s' % str(data))
conn.close()
if response.status not in (200, 204): # 204 ok for federation termination
logging.warning('SOAP error (%s) (on %s)' % (response.status, url))
raise SOAPException(url)
return data
def send_soap_request(request, profile):
'''Send the SOAP request hold by the profile'''
if not profile.msgUrl or not profile.msgBody:
raise SOAPException('Missing body or url')
#p = LibertyProvider.objects.get(entity_id=profile.remoteProviderId)
#return soap_call(profile.msgUrl, profile.msgBody, p.ssl_certificate)
return soap_call(profile.msgUrl, profile.msgBody, None)
def set_saml2_response_responder_status_code(response, code):
response.status = lasso.Samlp2Status()
response.status.statusCode = lasso.Samlp2StatusCode()
response.status.statusCode.value = lasso.SAML2_STATUS_CODE_RESPONDER
response.status.statusCode.statusCode = lasso.Samlp2StatusCode()
response.status.statusCode.statusCode.value = code
__root_refererer_re = re.compile('^(https?://[^/]*/?)')
def error_page(request, message, back = None, logger = None, warning=False):
'''View that show a simple error page to the user with a back link.
back - url for the back link, if None, return to root of the referer
or the local root.
'''
if not logger:
logger = logging
if warning:
logging.warning('Showing message %r on an error page' % message)
else:
logging.error('Showing message %r on an error page' % message)
if back is None:
referer = request.META.get('HTTP_REFERER')
if referer:
root_referer = __root_refererer_re.match(referer)
if root_referer:
back = root_referer.group(1)
if back is None:
back = '/'
redirection_timeout = getattr(settings, 'REDIRECTION_TIMEOUT_AFTER_ERROR', 2000)
return render_to_response('error.html', {'msg': message, 'back': back,
'redir_timeout': redirection_timeout},
context_instance=RequestContext(request))
def redirect_next(request, next):
if next:
return HttpResponseRedirect(next)
else:
return None
def soap_fault(request, faultcode='soap:Client', faultstring=None):
if faultstring:
faultstring = '\n <faultstring>%s</faultstring>\n' % faultstring
content = '''<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><soap:Fault>
<faultcode>%(faultcode)s</faultcode>%(faultstring)s
</soap:Fault></soap:Body>
</soap:Envelope>''' % locals()
return HttpResponse(content, mimetype = "text/xml")
def get_idp_options_policy(provider):
try:
return IdPOptionsSPPolicy.objects.get(name='All', enabled=True)
except IdPOptionsSPPolicy.DoesNotExist:
pass
if provider.identity_provider.enable_following_idp_options_policy:
if provider.identity_provider.idp_options_policy:
return provider.identity_provider.idp_options_policy
try:
return IdPOptionsSPPolicy.objects.get(name='Default', enabled=True)
except IdPOptionsSPPolicy.DoesNotExist:
pass
return None
def get_sp_options_policy(provider):
try:
return SPOptionsIdPPolicy.objects.get(name='All', enabled=True)
except SPOptionsIdPPolicy.DoesNotExist:
pass
try:
if provider.service_provider.enable_following_sp_options_policy:
if provider.service_provider.sp_options_policy:
return provider.service_provider.sp_options_policy
except:
pass
try:
return SPOptionsIdPPolicy.objects.get(name='Default', enabled=True)
except SPOptionsIdPPolicy.DoesNotExist:
pass
return None
def get_authorization_policy(provider):
try:
return AuthorizationSPPolicy.objects.get(name='All', enabled=True)
except AuthorizationSPPolicy.DoesNotExist:
pass
try:
if provider.identity_provider.enable_following_authorization_policy:
if provider.identity_provider.authorization_policy:
return provider.identity_provider.authorization_policy
except:
pass
try:
return AuthorizationSPPolicy.objects.get(name='Default', enabled=True)
except AuthorizationSPPolicy.DoesNotExist:
pass
return None
def attributesMatch(attributes, attributes_to_check):
attrs = AuthorizationAttributeMapping. \
objects.filter(map=attributes_to_check)
for attr in attrs:
if not attr.attribute_name in attributes:
raise Exception('Attribute %s not provided' %attr.attribute_name)
if not attr.attribute_value in attributes[attr.attribute_name]:
return False
return True
def authz_decision_cb(sender, request=None, attributes={},
provider=None, **kwargs):
p = get_authorization_policy(provider)
dic = {}
dic['authz'] = True
if p and p.attribute_map:
dic['authz'] = attributesMatch(attributes, p.attribute_map)
if not dic['authz']:
dic['message'] = \
_('Your access is denied. At least one attribute does not match.')
return dic
def get_session_not_on_or_after(assertion):
'''Extract the minimal value for the SessionNotOnOrAfter found in the given
assertion AuthenticationStatement(s).
'''
session_not_on_or_afters = []
if hasattr(assertion, 'authnStatement'):
for authn_statement in assertion.authnStatement:
if authn_statement.sessionNotOnOrAfter:
value = authn_statement.sessionNotOnOrAfter
try:
session_not_on_or_afters.append(saml2utils.iso8601_to_datetime(value))
except ValueError:
logging.getLogger(__name__).error('unable to parse SessionNotOnOrAfter value %s, will use default value for session length.', value)
if session_not_on_or_afters:
return reduce(min, session_not_on_or_afters)
return None