diff --git a/iam/api/migrations/0049_authevent_add_otl.py b/iam/api/migrations/0049_authevent_add_otl.py new file mode 100644 index 00000000..384454c3 --- /dev/null +++ b/iam/api/migrations/0049_authevent_add_otl.py @@ -0,0 +1,24 @@ +# Generated by Edulix on 2022-10-19 10:00 + +from django.db import models, migrations + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0048_authevent_allowed_statuses'), + ] + + operations = [ + migrations.AddField( + model_name='authevent', + name='support_otl_enabled', + field=models.BooleanField(default=False), + preserve_default=False, + ), + migrations.AddField( + model_name='authevent', + name='inside_authenticate_otl_period', + field=models.BooleanField(default=False), + preserve_default=False, + ), + ] diff --git a/iam/api/models.py b/iam/api/models.py index 69d422a8..510633e7 100644 --- a/iam/api/models.py +++ b/iam/api/models.py @@ -292,6 +292,8 @@ class AuthEvent(models.Model): created = models.DateTimeField(auto_now_add=True) admin_fields = JSONField(blank=True, null=True) + support_otl_enabled = models.BooleanField(default=False) + inside_authenticate_otl_period = models.BooleanField(default=False) has_ballot_boxes = models.BooleanField(default=True) allow_public_census_query = models.BooleanField(default=True) @@ -391,7 +393,9 @@ def serialize(self, restrict=False): 'openid_connect_providers': [ provider['public_info'] for provider in settings.OPENID_CONNECT_PROVIDERS_CONF - ] + ], + 'support_otl_enabled': self.support_otl_enabled, + 'inside_authenticate_otl_period': self.inside_authenticate_otl_period } def none_list(e): diff --git a/iam/api/tasks.py b/iam/api/tasks.py index 798734fd..4ac88447 100644 --- a/iam/api/tasks.py +++ b/iam/api/tasks.py @@ -93,15 +93,24 @@ def census_send_auth_task( census.append(item.user.user.id) elif "email" == auth_method and item.user.user.email: census.append(item.user.user.id) - + extend_errors = plugins.call("extend_send_message", e, len(census), kwargs) if extend_errors: logger.info("census_send_auth_task(pk = %r): errors" % pk) # Only can return one error at least for now return extend_errors[0] + force_create_otl = ( + e.support_otl_enabled and + isinstance(config, dict) and + 'force_create_otl' in config and + isinstance(config['force_create_otl'], bool) and + config.get('force_create_otl', False) + ) logger.info("census_send_auth_task(pk = %r): send_codes.apply_async" % pk) - send_codes.apply_async(args=[census, ip, auth_method, config, sender_uid, pk]) + send_codes.apply_async( + args=[census, ip, auth_method, config, sender_uid, pk, force_create_otl] + ) def launch_tally(auth_event): ''' diff --git a/iam/api/test_data.py b/iam/api/test_data.py index 3dbdf2a9..1546b7d4 100644 --- a/iam/api/test_data.py +++ b/iam/api/test_data.py @@ -1356,6 +1356,52 @@ ] } + +# election configuration: +# - uses email authentication +# - static codes (allows setting them when uploading census) +# - allows one time links +# - membership-id extra field that is required during OTL auth +auth_event_otp1 = { + "auth_method": "email", + "census": "open", + "auth_method_config": { + "authentication-action":{ + "mode":"vote", + "mode-config": None + }, + "registration-action":{ + "mode":"vote", + "mode-config":None + }, + "subject": "Confirm your email", + "msg": "Click __URL__ and put this code __CODE__", + "fixed-code": True + }, + "support_otl_enabled": True, + "extra_fields": [ + { + "name": "email", + "type": "email", + "required": True, + "min": 4, + "max": 255, + "required_on_authentication": True, + "match_against_census_on_otl_authentication": True + }, + { + "name": "membership-id", + "type": "text", + "required": False, + "unique": True, + "min": 1, + "max": 64, + "required_on_authentication": False, + "match_against_census_on_otl_authentication": True + } + ] +} + def get_auth_event19_census(auth_method): if 'email' in auth_method: return { diff --git a/iam/api/tests.py b/iam/api/tests.py index 216a3d09..9c7c3e80 100644 --- a/iam/api/tests.py +++ b/iam/api/tests.py @@ -28,7 +28,7 @@ from . import test_data from .models import ACL, AuthEvent, Action, BallotBox, TallySheet, SuccessfulLogin -from authmethods.models import Code, MsgLog +from authmethods.models import Code, MsgLog, OneTimeLink from authmethods import m_sms_otp from utils import HMACToken, verifyhmac, reproducible_json_dumps from authmethods.utils import get_cannonical_tlf, get_user_code @@ -998,6 +998,25 @@ def test_create_authevent_email(self): response = self.create_authevent(test_data.ae_email_default) self.assertEqual(response.status_code, 200) + def test_create_authevent_otl(self): + auth_event_data = copy.deepcopy(test_data.ae_email_default) + auth_event_data['support_otl_enabled'] = True + response = self.create_authevent(auth_event_data) + self.assertEqual(response.status_code, 200) + response_json = parse_json_response(response) + auth_event_id = response_json['id'] + self.assertEqual( + AuthEvent.objects.get(pk=auth_event_id).support_otl_enabled, + True + ) + + def test_create_authevent_invalid_otl(self): + auth_event_data = copy.deepcopy(test_data.ae_email_default) + # try creating with invalid data type + auth_event_data['support_otl_enabled'] = "foobar" + response = self.create_authevent(auth_event_data) + self.assertEqual(response.status_code, 400) + def test_create_authevent_sms(self): response = self.create_authevent(test_data.ae_sms_default) self.assertEqual(response.status_code, 200) @@ -1155,6 +1174,8 @@ def test_get_authevent(self): 'id': rid, 'users': 0, 'num_successful_logins_allowed': 0, + 'support_otl_enabled': False, + 'inside_authenticate_otl_period': False, 'hide_default_login_lookup_field': False, 'parent_id': None, 'children_election_info': None, @@ -6152,3 +6173,364 @@ def test_list_and_filter(self): self.assertEqual(response.status_code, 200) r = parse_json_response(response) self.assertEqual(len(r['events']), 1) + + +class TestOtl(TestCase): + def setUpTestData(): + flush_db_load_fixture() + + def setUp(self): + self.aeid_special = 1 + u = User( + username=test_data.admin['username'], + email=test_data.admin['email'] + ) + u.set_password(test_data.admin['password']) + u.save() + u.userdata.event = AuthEvent.objects.get(pk=1) + u.userdata.save() + self.user = u + + self.admin_auth_data = dict( + email=test_data.admin['email'], + code="ERGERG" + ) + c = Code( + user=self.user.userdata, + code=self.admin_auth_data['code'], + auth_event_id=self.aeid_special + ) + c.save() + + acl = ACL( + user=self.user.userdata, + object_type='AuthEvent', + perm='create', + object_id=0 + ) + acl.save() + + def test_create_otl_auth_event(self): + ''' + Create an OTL auth event + ''' + # authenticate as an admin + c = JClient() + response = c.authenticate(self.aeid_special, self.admin_auth_data) + self.assertEqual(response.status_code, 200) + + # create otp auth-event + response = c.post('/api/auth-event/', test_data.auth_event_otp1) + self.assertEqual(response.status_code, 200) + r = parse_json_response(response) + otp_auth_event_id = r['id'] + + # retrieve otp auth-event + response = c.get(f'/api/auth-event/{otp_auth_event_id}/', {}) + self.assertEqual(response.status_code, 200) + r = parse_json_response(response) + + # check the otp auth-event has support of otl enabled + self.assertEqual(r['events']['support_otl_enabled'], True) + self.assertEqual(r['events']['inside_authenticate_otl_period'], False) + + def test_toggle_otl_period_flag(self): + ''' + Checks that the API to enable and disable the OTL period does change + the inside_authenticate_otl_period flag + ''' + # authenticate as an admin + c = JClient() + response = c.authenticate(self.aeid_special, self.admin_auth_data) + self.assertEqual(response.status_code, 200) + + # create otp auth-event + response = c.post('/api/auth-event/', test_data.auth_event_otp1) + self.assertEqual(response.status_code, 200) + r = parse_json_response(response) + otp_auth_event_id = r['id'] + + # check there's no action related yet + self.assertEqual( + Action.objects.filter( + executer__id=self.user.id, + action_name='authevent:set-authenticate-otl-period', + event=otp_auth_event_id + ).count(), + 0 + ) + + # enable otl period + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/set-authenticate-otl-period/', + dict(set_authenticate_otl_period=True) + ) + self.assertEqual(response.status_code, 200) + + # retrieve otp auth-event and check otl period + response = c.get(f'/api/auth-event/{otp_auth_event_id}/', {}) + self.assertEqual(response.status_code, 200) + r = parse_json_response(response) + self.assertEqual(r['events']['inside_authenticate_otl_period'], True) + + # check a new action was registered + self.assertEqual( + Action.objects.filter( + executer__id=self.user.id, + action_name='authevent:set-authenticate-otl-period', + event=otp_auth_event_id + ).count(), + 1 + ) + + # enable otl period again (no change) + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/set-authenticate-otl-period/', + dict(set_authenticate_otl_period=True) + ) + self.assertEqual(response.status_code, 200) + + # retrieve otp auth-event and check otl period + response = c.get(f'/api/auth-event/{otp_auth_event_id}/', {}) + self.assertEqual(response.status_code, 200) + r = parse_json_response(response) + self.assertEqual(r['events']['inside_authenticate_otl_period'], True) + + # check a new action was registered + self.assertEqual( + Action.objects.filter( + executer__id=self.user.id, + action_name='authevent:set-authenticate-otl-period', + event=otp_auth_event_id + ).count(), + 2 + ) + + # disable otl period + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/set-authenticate-otl-period/', + dict(set_authenticate_otl_period=False) + ) + self.assertEqual(response.status_code, 200) + + # retrieve otp auth-event and check otl period + response = c.get(f'/api/auth-event/{otp_auth_event_id}/', {}) + self.assertEqual(response.status_code, 200) + r = parse_json_response(response) + self.assertEqual(r['events']['inside_authenticate_otl_period'], False) + + # check a new action was registered + self.assertEqual( + Action.objects.filter( + executer__id=self.user.id, + action_name='authevent:set-authenticate-otl-period', + event=otp_auth_event_id + ).count(), + 3 + ) + + def test_invalid_set_otl_period(self): + ''' + Checks multiple cases in which the setting of the OTL period should + fail: + - no authentication + - invalid input data + - invalid permissions + ''' + # authenticate as an admin + c = JClient() + response = c.authenticate(self.aeid_special, self.admin_auth_data) + self.assertEqual(response.status_code, 200) + + # create otp auth-event + response = c.post('/api/auth-event/', test_data.auth_event_otp1) + self.assertEqual(response.status_code, 200) + r = parse_json_response(response) + otp_auth_event_id = r['id'] + + # invalid input data + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/set-authenticate-otl-period/', + dict(set_authenticate_otl_period="invalid") + ) + self.assertEqual(response.status_code, 400) + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/set-authenticate-otl-period/', + dict(whatever="invalid") + ) + self.assertEqual(response.status_code, 400) + + # log out and try to set otl period + c.set_auth_token(None) + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/set-authenticate-otl-period/', + dict(set_authenticate_otl_period=True) + ) + self.assertEqual(response.status_code, 403) + + # check there's no action related yet + self.assertEqual( + Action.objects.filter( + executer__id=self.user.id, + action_name='authevent:set-authenticate-otl-period', + event=otp_auth_event_id + ).count(), + 0 + ) + + # retrieve otp auth-event and check otl period didn't change + response = c.get(f'/api/auth-event/{otp_auth_event_id}/', {}) + self.assertEqual(response.status_code, 200) + r = parse_json_response(response) + self.assertEqual(r['events']['inside_authenticate_otl_period'], False) + + @override_settings(**override_celery_data) + def test_otl_flow(self): + ''' + Test the whole OTL flow, from election creation to voter authentication + ''' + # authenticate as an admin + c = JClient() + response = c.authenticate(self.aeid_special, self.admin_auth_data) + self.assertEqual(response.status_code, 200) + + # create otp auth-event + response = c.post('/api/auth-event/', test_data.auth_event_otp1) + self.assertEqual(response.status_code, 200) + r = parse_json_response(response) + otp_auth_event_id = r['id'] + + # add user to the census + response = c.census( + otp_auth_event_id, + { + "field-validation": "enabled", + "census": [ + { + "email": "baaa@aaa.com", + "membership-id": "1234" + } + ] + } + ) + self.assertEqual(response.status_code, 200) + + # enable otl period + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/set-authenticate-otl-period/', + dict(set_authenticate_otl_period=True) + ) + self.assertEqual(response.status_code, 200) + + # enable authentication period + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/started/', + dict() + ) + self.assertEqual(response.status_code, 200) + + # send authentication, which should generate an OTL + self.assertEqual(OneTimeLink.objects.count(), 0) + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/census/send_auth/', + { + "msg": "Vote in __URL__ but obtain code in __OTL__", + "subject": "Test Vote", + "user-ids": None, + "auth-method": "email" + } + ) + self.assertEqual(response.status_code, 200) + + self.assertEqual(OneTimeLink.objects.count(), 1) + otl = OneTimeLink.objects.filter()[0] + + # perform otp authentication + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/authenticate-otl/', + { + "email": "baaa@aaa.com", + "membership-id": "1234", + "__otl_secret": str(otl.secret) + } + ) + self.assertEqual(response.status_code, 200) + response_json = parse_json_response(response) + self.assertTrue( + 'code' in response_json and isinstance(response_json['code'], str) + ) + code = response_json['code'] + + # perform authentication using otp + c.authenticate( + otp_auth_event_id, + { + 'email': "baaa@aaa.com", + 'code': code + } + ) + self.assertEqual(response.status_code, 200) + + # authenticate as an admin again + c = JClient() + response = c.authenticate(self.aeid_special, self.admin_auth_data) + self.assertEqual(response.status_code, 200) + + # resend the OTL, creating a new OTL and invalidating previous one + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/census/send_auth/', + { + "msg": "Vote in __URL__ but obtain code in __OTL__", + "subject": "Test Vote", + "user-ids": None, + "auth-method": "email", + "force_create_otl": True + } + ) + self.assertEqual(response.status_code, 200) + + # sanity checks + self.assertEqual(OneTimeLink.objects.count(), 2) + [new_otl, old_otl] = OneTimeLink.objects.order_by("-created") + self.assertEqual(old_otl.secret, otl.secret) + self.assertEqual(old_otl.is_enabled, False) + self.assertEqual(new_otl.is_enabled, True) + self.assertTrue(new_otl.secret != old_otl.secret) + + # perform otp authentication with old code fails + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/authenticate-otl/', + { + "email": "baaa@aaa.com", + "membership-id": "1234", + "__otl_secret": str(old_otl.secret) + } + ) + self.assertEqual(response.status_code, 400) + + # perform otp auth with new code works + response = c.post( + f'/api/auth-event/{otp_auth_event_id}/authenticate-otl/', + { + "email": "baaa@aaa.com", + "membership-id": "1234", + "__otl_secret": str(new_otl.secret) + } + ) + self.assertEqual(response.status_code, 200) + response_json = parse_json_response(response) + new_code = response_json['code'] + + # the new code obtained is the same as before because this election is + # configured to use static codes + self.assertEqual(code, new_code) + + # the code can still be used for authentication + c.authenticate( + otp_auth_event_id, + { + 'email': "baaa@aaa.com", + 'code': new_code + } + ) + self.assertEqual(response.status_code, 200) diff --git a/iam/api/urls.py b/iam/api/urls.py index ab7699a7..afa4785d 100644 --- a/iam/api/urls.py +++ b/iam/api/urls.py @@ -33,6 +33,7 @@ url(r'^auth-event/(?P\d+)/publish-results/$', views.publish_results, name='publish-results'), url(r'^auth-event/(?P\d+)/unpublish-results/$', views.unpublish_results, name='unpublish-results'), url(r'^auth-event/(?P\d+)/set-public-candidates/$', views.set_public_candidates, name='set-public-candidates'), + url(r'^auth-event/(?P\d+)/set-authenticate-otl-period/$', views.set_authenticate_otl_period, name='set-authenticate-otl-period'), url(r'^auth-event/(?P\d+)/archive/$', views.archive, name='archive'), url(r'^auth-event/(?P\d+)/unarchive/$', views.unarchive, name='unarchive'), url(r'^auth-event/(?P\d+)/callback/$', views.callback, name='callback'), @@ -51,6 +52,7 @@ url(r'^auth-event/(?P\d+)/generate-auth-code/$', views.generate_auth_code, name='generate_auth_code'), url(r'^auth-event/(?P\d+)/register/$', views.register, name='register'), url(r'^auth-event/(?P\d+)/authenticate/$', views.authenticate, name='authenticate'), + url(r'^auth-event/(?P\d+)/authenticate-otl/$', views.authenticate_otl, name='authenticate_otl'), url(r'^auth-event/(?P\d+)/successful_login/(?P\w+)$', views.successful_login, name='successful_login'), url(r'^auth-event/(?P\d+)/resend_auth_code/$', views.resend_auth_code, name='resend_auth_code'), url(r'^auth-event/(?P\d+)/census/send_auth/$', views.census_send_auth, name='census_send_auth'), diff --git a/iam/api/views.py b/iam/api/views.py index 32d16acc..5b1a7882 100644 --- a/iam/api/views.py +++ b/iam/api/views.py @@ -35,6 +35,7 @@ import plugins from authmethods import ( auth_authenticate, + auth_authenticate_otl, auth_census, auth_register, auth_resend_auth_code, @@ -640,6 +641,46 @@ def post(self, request, pk): authenticate = Authenticate.as_view() +class AuthenticateOtl(View): + ''' Authenticate into the iam ''' + + def post(self, request, pk): + try: + e = get_object_or_404( + AuthEvent, + pk=pk, + support_otl_enabled=True, + inside_authenticate_otl_period=True + ) + except: + return json_response( + status=400, + error_codename=ErrorCodes.BAD_REQUEST + ) + + if not hasattr(request.user, 'account'): + error_kwargs = plugins.call("extend_auth", e) + if error_kwargs: + return json_response(**error_kwargs[0]) + try: + data = auth_authenticate_otl(e, request) + except: + return json_response( + status=400, + error_codename=ErrorCodes.BAD_REQUEST + ) + + if data and 'status' in data and data['status'] == 'ok': + return json_response(data) + else: + return json_response( + status=400, + error_codename=data.get('error_codename'), + message=data.get('msg', '-') + ) +authenticate_otl = AuthenticateOtl.as_view() + + class GenerateAuthCode(View): ''' Admin generates auth code for an user''' @@ -1617,6 +1658,13 @@ def post(request, pk=None): return json_response( status=400, error_codename="INVALID_BALLOT_BOXES") + + # check if it has support_otl_enabled + support_otl_enabled = req.get('support_otl_enabled', False) + if not isinstance(support_otl_enabled, bool): + return json_response( + status=400, + error_codename="INVALID_SUPPORT_OTL_ENABLED") # check if it has hide_default_login_lookup_field hide_default_login_lookup_field = req.get( @@ -1699,7 +1747,8 @@ def post(request, pk=None): based_in=based_in, has_ballot_boxes=has_ballot_boxes, hide_default_login_lookup_field=hide_default_login_lookup_field, - allow_public_census_query=allow_public_census_query + allow_public_census_query=allow_public_census_query, + support_otl_enabled=support_otl_enabled ) # If the election exists, we are doing an update. Else, we are # doing an insert. We use this update method instead of just @@ -3075,6 +3124,53 @@ def post(self, request, pk): set_public_candidates = login_required(SetPublicCandidatesView.as_view()) + +class SetAuthenticateOtlPeriodView(View): + + def post(self, request, pk): + ''' + Sets the Authenticate OTL Period + ''' + # check permissions + permission_required( + request.user, + 'AuthEvent', + ['edit', 'set-authenticate-otl-period'], + pk + ) + + # parse input + req_json = parse_json_request(request) + if ( + "set_authenticate_otl_period" not in req_json or + not isinstance(req_json['set_authenticate_otl_period'], bool) + ): + return json_response( + status=400, + error_codename=ErrorCodes.BAD_REQUEST + ) + authenticate_otl_period = req_json['set_authenticate_otl_period'] + + auth_event = get_object_or_404(AuthEvent, pk=pk) + auth_event.inside_authenticate_otl_period = authenticate_otl_period + auth_event.save() + action = Action( + executer=request.user, + receiver=None, + action_name="authevent:set-authenticate-otl-period", + event=auth_event, + metadata={ + "authenticate_otl_period": authenticate_otl_period + } + ) + action.save() + + return json_response() + +set_authenticate_otl_period = login_required( + SetAuthenticateOtlPeriodView.as_view() +) + class AllowTallyView(View): def post(self, request, pk): diff --git a/iam/authmethods/__init__.py b/iam/authmethods/__init__.py index 61191ed6..5b4d0810 100644 --- a/iam/authmethods/__init__.py +++ b/iam/authmethods/__init__.py @@ -13,6 +13,9 @@ def auth_register(event, data): def auth_authenticate(event, data): return METHODS[event.auth_method].authenticate(event, data) +def auth_authenticate_otl(event, data): + return METHODS[event.auth_method].authenticate_otl(event, data) + def auth_resend_auth_code(event, data): return METHODS[event.auth_method].resend_auth_code(event, data) diff --git a/iam/authmethods/m_email.py b/iam/authmethods/m_email.py index fe99356c..e6acdad8 100644 --- a/iam/authmethods/m_email.py +++ b/iam/authmethods/m_email.py @@ -50,7 +50,8 @@ resend_auth_code, generate_auth_code, stack_trace_str, - get_user_code + get_user_code, + authenticate_otl ) from django.db.models import Q from contracts.base import check_contract, JsonTypeEncoder @@ -792,4 +793,11 @@ def generate_auth_code(self, auth_event, request): logger_name="Email" ) + def authenticate_otl(self, auth_event, request): + return authenticate_otl( + auth_event=auth_event, + request=request, + logger_name="Email" + ) + register_method('email', Email) diff --git a/iam/authmethods/m_email_otp.py b/iam/authmethods/m_email_otp.py index 6fe72b8d..332124e4 100644 --- a/iam/authmethods/m_email_otp.py +++ b/iam/authmethods/m_email_otp.py @@ -50,6 +50,7 @@ post_verify_fields_on_auth, resend_auth_code, generate_auth_code, + authenticate_otl, stack_trace_str, get_user_code, disable_previous_user_codes @@ -789,6 +790,13 @@ def authenticate(self, auth_event, request): return return_auth_data('EmailOtp', req, request, user) + def authenticate_otl(self, auth_event, request): + return authenticate_otl( + auth_event=auth_event, + request=request, + logger_name="EmailOtp" + ) + def resend_auth_code(self, auth_event, request): return resend_auth_code( auth_event=auth_event, diff --git a/iam/authmethods/m_emailpwd.py b/iam/authmethods/m_emailpwd.py index d867d0aa..049635d4 100644 --- a/iam/authmethods/m_emailpwd.py +++ b/iam/authmethods/m_emailpwd.py @@ -39,7 +39,8 @@ post_verify_fields_on_auth, resend_auth_code, generate_auth_code, - stack_trace_str + stack_trace_str, + authenticate_otl ) LOGGER = logging.getLogger('iam') @@ -267,6 +268,14 @@ def resend_auth_code(self, auth_event, request): default_pipelines=EmailPassword.PIPELINES ) + def authenticate_otl(self, auth_event, request): + return authenticate_otl( + auth_event=auth_event, + request=request, + logger_name="EmailPassword" + ) + + def generate_auth_code(self, auth_event, request): return generate_auth_code( auth_event=auth_event, diff --git a/iam/authmethods/m_pwd.py b/iam/authmethods/m_pwd.py index 5088cea0..312ab3fc 100644 --- a/iam/authmethods/m_pwd.py +++ b/iam/authmethods/m_pwd.py @@ -40,7 +40,8 @@ resend_auth_code, generate_auth_code, stack_trace_str, - json_response + json_response, + authenticate_otl ) LOGGER = logging.getLogger('iam') @@ -285,6 +286,14 @@ def resend_auth_code(self, auth_event, request): default_pipelines=Password.PIPELINES ) + def authenticate_otl(self, auth_event, request): + return authenticate_otl( + auth_event=auth_event, + request=request, + logger_name="Password" + ) + + def generate_auth_code(self, auth_event, request): return generate_auth_code( auth_event=auth_event, diff --git a/iam/authmethods/m_smart_link.py b/iam/authmethods/m_smart_link.py index 515c1971..3d52b492 100644 --- a/iam/authmethods/m_smart_link.py +++ b/iam/authmethods/m_smart_link.py @@ -46,6 +46,7 @@ resend_auth_code, generate_auth_code, stack_trace_str, + authenticate_otl ) from contracts.base import check_contract @@ -447,6 +448,13 @@ def resend_auth_code(self, auth_event, request): default_pipelines=SmartLink.PIPELINES ) + def authenticate_otl(self, auth_event, request): + return authenticate_otl( + auth_event=auth_event, + request=request, + logger_name="SmartLink" + ) + def generate_auth_code(self, auth_event, request): return generate_auth_code( auth_event=auth_event, diff --git a/iam/authmethods/m_sms.py b/iam/authmethods/m_sms.py index 8e740699..18155fb4 100644 --- a/iam/authmethods/m_sms.py +++ b/iam/authmethods/m_sms.py @@ -51,7 +51,8 @@ resend_auth_code, generate_auth_code, stack_trace_str, - get_user_code + get_user_code, + authenticate_otl ) import plugins from . import register_method @@ -781,6 +782,13 @@ def authenticate(self, auth_event, request): return return_auth_data('Sms', req, request, user) + def authenticate_otl(self, auth_event, request): + return authenticate_otl( + auth_event=auth_event, + request=request, + logger_name="Sms" + ) + def resend_auth_code(self, auth_event, request): return resend_auth_code( auth_event=auth_event, diff --git a/iam/authmethods/m_sms_otp.py b/iam/authmethods/m_sms_otp.py index e6440ddd..f981d317 100644 --- a/iam/authmethods/m_sms_otp.py +++ b/iam/authmethods/m_sms_otp.py @@ -51,7 +51,8 @@ generate_auth_code, stack_trace_str, get_user_code, - disable_previous_user_codes + disable_previous_user_codes, + authenticate_otl ) import plugins from . import register_method @@ -782,6 +783,13 @@ def authenticate(self, auth_event, request): return return_auth_data('SmsOtp', req, request, user) + def authenticate_otl(self, auth_event, request): + return authenticate_otl( + auth_event=auth_event, + request=request, + logger_name="SmsOtp" + ) + def resend_auth_code(self, auth_event, request): return resend_auth_code( auth_event=auth_event, diff --git a/iam/authmethods/migrations/0010_one_time_link.py b/iam/authmethods/migrations/0010_one_time_link.py new file mode 100644 index 00000000..d92fdcb6 --- /dev/null +++ b/iam/authmethods/migrations/0010_one_time_link.py @@ -0,0 +1,55 @@ +# Generated by Edulix on 2022-10-19 10:00 + +import uuid +from django.db import migrations, models +import django + +class Migration(migrations.Migration): + + dependencies = [ + ('authmethods', '0009_code_is_enabled'), + ] + + operations = [ + migrations.CreateModel( + name='OneTimeLink', + fields=[ + ( + 'id', + models.AutoField( + serialize=False, + verbose_name='ID', + auto_created=True, + primary_key=True + ) + ), + ( + 'user', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="one_time_links", + to="api.UserData" + ) + ), + ('auth_event_id', models.IntegerField()), + ( + 'secret', + models.UUIDField( + default=uuid.uuid4, + editable=False, + db_index=True + ) + ), + ('created', models.DateTimeField(auto_now_add=True)), + ('used', models.DateTimeField( + auto_now=False, + auto_now_add=False, + null=True, + blank=True + )), + ('is_enabled', models.BooleanField(default=True)), + ], + options={}, + bases=(models.Model,), + ) + ] diff --git a/iam/authmethods/models.py b/iam/authmethods/models.py index 14b1d4ed..243a8ae1 100644 --- a/iam/authmethods/models.py +++ b/iam/authmethods/models.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU Affero General Public License # along with iam. If not, see . +import uuid from django.db import models from jsonfield import JSONField @@ -59,3 +60,39 @@ class Code(models.Model): created = models.DateTimeField(auto_now_add=True) auth_event_id = models.IntegerField() is_enabled = models.BooleanField(default=True) + +class OneTimeLink(models.Model): + ''' + Stores information related to "secret" One Time Links (OTLs) that are used + to obtain voter authentication codes. + ''' + # The OTL will be valid only for this user + user = models.ForeignKey( + UserData, + models.CASCADE, + related_name="one_time_links" + ) + + # The OTL will be valid only for this auth event + auth_event_id = models.IntegerField() + + # stores the secret that is part of the one time link and makes it secure + # because it's difficult to guess. See for security + # https://stackoverflow.com/questions/41505448/is-python-uuid-uuid4-strong-enough-for-password-reset-links + secret = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + + # Time at which this link was created. If a user has multiple enabled links, + # only the last one should work. + created = models.DateTimeField(auto_now_add=True) + + # time at which the link was used - the link should be used only once so + # when used, it should be disabled + used = models.DateTimeField( + auto_now=False, + auto_now_add=False, + null=True, + blank=True + ) + + # is the link enabled? it could be manually disabled + is_enabled = models.BooleanField(default=True) diff --git a/iam/authmethods/utils.py b/iam/authmethods/utils.py index 088b3d81..546bd8e8 100644 --- a/iam/authmethods/utils.py +++ b/iam/authmethods/utils.py @@ -39,7 +39,8 @@ genhmac, stack_trace_str, generate_code, - send_codes + send_codes, + get_or_create_code ) from pipelines.base import execute_pipeline, PipeReturnvalue @@ -603,6 +604,9 @@ def check_field_value(definition, field, req=None, ae=None, step='register'): return msg elif definition.get('type') == 'otp-code': return msg + if step == 'authenticate-otl': + if not definition.get('match_against_census_on_otl_authentication'): + return msg if step == 'census' and definition.get('type') == 'captcha': return msg if field is None: @@ -670,25 +674,53 @@ def check_captcha(code, answer): return 'Invalid captcha' return '' -def check_fields_in_request(req, ae, step='register', validation=True): - """ Checked fields in extra_fields are correct, checked the type of field and the value if - validation is True. """ - msg = '' +def check_fields_in_request( + request_data, + auth_event, + step='register', + validation=True +): + ''' + Checked fields in extra_fields are correct, checked the type of field and + the value if validation is True. + ''' + error_messages = '' - if ae.extra_fields: - if len(req) > settings.MAX_EXTRA_FIELDS * 2: + if auth_event.extra_fields: + if len(request_data) > settings.MAX_EXTRA_FIELDS * 2: return "Number of fields is bigger than allowed fields." - for extra in ae.extra_fields: - canonize_extra_field(extra, req) - msg += check_field_type(extra, req.get(extra.get('name')), step) - canonize_extra_field(extra, req) + for extra in auth_event.extra_fields: + canonize_extra_field(extra, request_data) + error_messages += check_field_type( + extra, + request_data.get(extra.get('name')), + step + ) + canonize_extra_field(extra, request_data) if validation: - msg += check_field_value(extra, req.get(extra.get('name')), req, ae, step) - if not msg and extra.get('type') == 'captcha' and step != 'census': - if (step == 'register' and extra.get('required')) or\ - (step == 'authenticate' and extra.get('required_on_authentication')): - msg += check_captcha(req.get('captcha_code'), req.get(extra.get('name'))) - return msg + error_messages += check_field_value( + extra, + request_data.get(extra.get('name')), + request_data, + auth_event, + step + ) + if ( + not error_messages and + extra.get('type') == 'captcha' and + step != 'census' + ): + if ( + step == 'register' and extra.get('required') + ) or ( + step == 'authenticate' and + extra.get('required_on_authentication') + ): + error_messages += check_captcha( + request_data.get('captcha_code'), + request_data.get(extra.get('name')) + ) + return error_messages def have_captcha(ae, step='register'): @@ -1158,6 +1190,127 @@ def generate_auth_code(auth_event, request, logger_name): user ) +def authenticate_otl( + auth_event, + request, + logger_name +): + ''' + Implements the authenticate_otl call for an authentication method. + ''' + from authmethods.models import OneTimeLink + from api.models import Action + request_data = json.loads(request.body.decode('utf-8')) + + def ret_error(log_error_message, error_message, error_codename): + LOGGER.error( + f"{logger_name}.authenticate_otl error\n"\ + f"{log_error_message}\n"\ + f"{error_message}\n"\ + f"authevent '{auth_event}'\n"\ + f"request '{request_data}'\n"\ + f"Stack trace: \n{stack_trace_str()}" + ) + return dict( + status='nok', + msg=error_message, + error_codename=error_codename + ) + + if auth_event.parent is not None: + return ret_error( + log_error_message='you can only do authenticate_otl to parent elections', + error_message="Incorrect data", + error_codename="invalid_credentials" + ) + + if auth_event.support_otl_enabled is not True: + return ret_error( + log_error_message='election without OTL enabled', + error_message="Incorrect data", + error_codename="invalid_credentials" + ) + + if auth_event.inside_authenticate_otl_period is not True: + return ret_error( + log_error_message='election outside OTL period', + error_message="Incorrect data", + error_codename="invalid_credentials" + ) + + error_message = '' + + error_message += check_fields_in_request( + request_data, + auth_event, + 'authenticate-otl' + ) + if error_message: + return ret_error( + log_error_message=error_message, + error_message="Incorrect data", + error_codename="invalid_credentials" + ) + + otl_secret = request_data.get('__otl_secret') + if '__otl_secret' not in request_data: + return ret_error( + log_error_message=error_message, + error_message="Incorrect data", + error_codename="invalid_credentials" + ) + + try: + otl = OneTimeLink\ + .objects\ + .filter( + secret=otl_secret, + used=None, + is_enabled=True, + auth_event_id=auth_event.id + )\ + .order_by('-created')\ + .first() + query = get_base_auth_query(auth_event) + query = query & Q(userdata=otl.user) + query = get_required_fields_on_auth( + request_data, + auth_event, + query, + selector='match_against_census_on_otl_authentication' + ) + user = User.objects.get(query) + except: + return ret_error( + log_error_message="user not found with given characteristics", + error_message="Incorrect data", + error_codename="invalid_credentials" + ) + + code = get_or_create_code(user) + otl.used = timezone.now() + otl.is_enabled = False + otl.save() + + action = Action( + executer=user, + receiver=user, + action_name='user:authenticate-otl', + event=auth_event, + metadata=get_trimmed_user_req(request_data, auth_event) + ) + action.save() + + LOGGER.info( + f"{logger_name}.authenticate_otl.\n"\ + f"Returning auth-code={code} for user.id='{user.id}'\n"\ + f"client ip '{get_client_ip(request)}'\n"\ + f"authevent '{auth_event}'\n"\ + f"request '{request_data}'\n"\ + f"Stack trace: \n{stack_trace_str()}" + ) + return dict(status='ok', code=code, username=user.username) + def resend_auth_code( auth_event, request, @@ -1286,40 +1439,47 @@ def ret_error(log_error_message, error_message, error_codename): ) return dict(status='ok', user=user) -def get_required_fields_on_auth(req, ae, q): +def get_required_fields_on_auth( + request_data, + auth_event, + query, + selector='required_on_authentication' +): ''' Modifies a Q query adding required_on_authentication fields with the values from the http request, used to filter users ''' - if ae.extra_fields: - for field in ae.extra_fields: - if not field.get('required_on_authentication'): + if auth_event.extra_fields: + for field in auth_event.extra_fields: + if not field.get(selector): continue # Raise exception if a required field is not provided. # It will be catched by parent as an error. typee = field.get('type') if ( - field.get('name') not in req and + field.get('name') not in request_data and typee not in ['password', 'otp-code'] ): raise Exception() - value = req.get(field.get('name'), '') + value = request_data.get(field.get('name'), '') if typee == 'email': - q = q & Q(email__iexact=value) + query = query & Q(email__iexact=value) elif typee == 'tlf': - q = q & Q(userdata__tlf=value) + query = query & Q(userdata__tlf=value) elif typee in ['password', 'otp-code']: # we verify this later im post_verify_fields_on_auth continue else: if typee == 'text' and field.get('name') == 'username': - q = q & Q(username=value) + query = query & Q(username=value) else: - q = q & Q(userdata__metadata__contains={field.get('name'): value}) + query = query & Q( + userdata__metadata__contains={field.get('name'): value} + ) - return q + return query def give_perms(u, ae): pipe = ae.auth_method_config.get('pipeline') diff --git a/iam/iam/test_settings.py b/iam/iam/test_settings.py index 57652c65..602f3b98 100644 --- a/iam/iam/test_settings.py +++ b/iam/iam/test_settings.py @@ -248,6 +248,7 @@ class CeleryConfig: HOME_URL = "https://sequent.example.com/#/election/__EVENT_ID__/public/home" SMS_AUTH_CODE_URL = "https://sequent.example.com/#/election/__EVENT_ID__/public/login/__RECEIVER__" EMAIL_AUTH_CODE_URL = "https://sequent.example.com/#/election/__EVENT_ID__/public/login/__RECEIVER__" +OTL_URL = "https://sequent.example.com/election/__EVENT_ID__/otl/__SECRET__" SEQUENT_ELECTIONS_BASE = [] diff --git a/iam/utils.py b/iam/utils.py index 05936da8..e555323f 100644 --- a/iam/utils.py +++ b/iam/utils.py @@ -466,6 +466,10 @@ def send_email_code( ): template_dict[field['slug']] = user.userdata.metadata[field['name']] + + if auth_event.support_otl_enabled: + template_dict['otl'] = get_or_create_otl(user) + # base_msg is the base template, allows the iam superadmin to configure # a prefix or suffix to all messages # email @@ -595,6 +599,9 @@ def send_sms_code( ): template_dict[field['slug']] = user.userdata.metadata[field['name']] + if auth_event.support_otl_enabled: + template_dict['otl'] = get_or_create_otl(user) + # base_msg is the base template, allows the iam superadmin to configure # a prefix or suffix to all messages base_message_body = settings.SMS_BASE_TEMPLATE @@ -623,6 +630,55 @@ def send_sms_code( ) db_message.save() +def get_or_create_code(user): + auth_event = user.userdata.event + auth_config = auth_event.auth_method_config.get('config') + is_fixed_code = type(auth_config) is dict and auth_config.get('fixed-code') + code = None + if is_fixed_code: + from authmethods.models import Code + last_code = Code.objects.filter( + user=user.userdata, + auth_event_id=user.userdata.event.id, + is_enabled=True + ).order_by('created').last() + if last_code: + code = last_code.code + + if not code: + code = generate_code(user.userdata).code + return code + +def get_or_create_otl(user): + ''' + Gets or creates an One Time Link + ''' + from authmethods.models import OneTimeLink + auth_event = user.userdata.event + otlf_config = dict( + user=user.userdata, + used=None, + is_enabled=True, + auth_event_id=auth_event.id + ) + otl = OneTimeLink\ + .objects\ + .filter(**otlf_config)\ + .order_by('-created')\ + .first() + if otl is None: + otl = OneTimeLink(**otlf_config) + otl.save() + + otl_url = template_replace_data( + settings.OTL_URL, + dict( + event_id=auth_event.id, + secret=otl.secret + ) + ) + return otl_url + def send_code( user, ip_address, @@ -660,19 +716,7 @@ def send_code( auth_method = auth_event.auth_method auth_config = auth_event.auth_method_config.get('config') - is_fixed_code = type(auth_config) is dict and auth_config.get('fixed-code') - if is_fixed_code and not code: - from authmethods.models import Code - last_code = Code.objects.filter( - user=user.userdata, - auth_event_id=user.userdata.event.id, - is_enabled=True - ).order_by('created').last() - if last_code: - code = last_code.code - - if not code: - code = generate_code(user.userdata).code + code = get_or_create_code(user) base_config = ( auth_config if not config @@ -859,9 +903,11 @@ def send_codes( auth_method=None, config=None, sender_uid=None, - eid=None + eid=None, + force_create_otl=False ): from api.models import Action, AuthEvent + from authmethods.models import OneTimeLink # delay between send code calls delay = 0 @@ -870,7 +916,6 @@ def send_codes( for info in extend_info: delay = info - sender = User.objects.get(pk=sender_uid) if sender_uid else None auth_event = AuthEvent.objects.get(pk=eid) if eid else None @@ -885,6 +930,25 @@ def send_codes( metadata=dict() ) action.save() + if force_create_otl: + # invalidate old otls + old_otls = OneTimeLink.objects.filter( + user=user.userdata, + auth_event_id=auth_event.id, + is_enabled=True + ) + for old_otl in old_otls: + old_otl.is_enabled = False + OneTimeLink.objects.bulk_update(old_otls, ['is_enabled']) + + # create a new one + otl = OneTimeLink( + user=user.userdata, + used=False, + is_enabled=True, + auth_event_id=auth_event.id + ) + otl.save() send_code(user, ip, config, auth_method_override=auth_method) if delay > 0: sleep(delay) @@ -942,7 +1006,11 @@ def send_codes( 'templates', # user by otp-code - 'source_field' + 'source_field', + + # Used to match this extra field during authentication in One Time Links + # (OTLs). + 'match_against_census_on_otl_authentication' ) REQUIRED_FIELDS = ('name', 'type', 'required_on_authentication') VALID_PIPELINES = (