From 6b0ff96ada7b94ae00418a2a9984515ffe49627a Mon Sep 17 00:00:00 2001 From: Odumosu Dipo Date: Mon, 21 Oct 2024 17:51:23 +0100 Subject: [PATCH 01/10] feat: back end support for PWA 2FA --- apollo/participants/api/views.py | 108 ++++++++++++++++++++-- apollo/participants/views_participants.py | 2 + apollo/settings.py | 4 + 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/apollo/participants/api/views.py b/apollo/participants/api/views.py index c1f96a408..45ee75f11 100644 --- a/apollo/participants/api/views.py +++ b/apollo/participants/api/views.py @@ -1,12 +1,19 @@ # -*- coding: utf-8 -*- +import random +import string from http import HTTPStatus -from flask import g, jsonify, request +from flask import g, jsonify, make_response, request from flask_apispec import MethodResource, marshal_with, use_kwargs from flask_babel import gettext from flask_jwt_extended import ( - create_access_token, get_jwt, get_jwt_identity, jwt_required, - set_access_cookies, unset_access_cookies) + create_access_token, + get_jwt, + get_jwt_identity, + jwt_required, + set_access_cookies, + unset_access_cookies, +) from sqlalchemy import and_, bindparam, func, or_, text, true from sqlalchemy.orm import aliased from sqlalchemy.orm.exc import NoResultFound @@ -20,13 +27,22 @@ from apollo.formsframework.api.schema import FormSchema from apollo.formsframework.models import Form from apollo.locations.models import Location +from apollo.messaging.tasks import send_message from apollo.participants.api.schema import ParticipantSchema from apollo.participants.models import ( - Participant, ParticipantFirstNameTranslations, - ParticipantFullNameTranslations, ParticipantLastNameTranslations, - ParticipantOtherNamesTranslations, ParticipantRole, ParticipantSet) + Participant, + ParticipantFirstNameTranslations, + ParticipantFullNameTranslations, + ParticipantLastNameTranslations, + ParticipantOtherNamesTranslations, + ParticipantRole, + ParticipantSet, +) from apollo.submissions.models import Submission +OTP_LENGTH = 6 +OTP_LIFETIME = 5 * 60 + @marshal_with(ParticipantSchema) @use_kwargs({'event_id': fields.Int()}, location='query') @@ -158,6 +174,13 @@ def login(): return response + if getattr(settings, "PWA_TWO_FACTOR", False): + return _process_2fa_login(participant) + else: + return _process_login(participant) + + +def _process_login(participant: Participant): access_token = create_access_token( identity=str(participant.uuid), fresh=True) @@ -174,7 +197,7 @@ def login(): 'other_names': participant.other_names, 'last_name': participant.last_name, 'full_name': participant.full_name, - 'participant_id': participant_id, + 'participant_id': participant.participant_id, 'location': participant.location.name, 'locale': participant.locale, }, @@ -194,6 +217,75 @@ def login(): return resp +def _generate_or_retrieve_otp(key: str) -> str | None: + client = red._redis_client + if client: + res: bytes | None = client.get(key) + if res is not None: + return res.decode() + + random.seed() + otp_code = "".join(random.choice(string.digits) for i in range(OTP_LENGTH)) + client.set(key, otp_code, ex=OTP_LIFETIME) + return otp_code + + +def _process_2fa_login(participant: Participant): + key = f"2fa:{participant.uuid}" + result = _generate_or_retrieve_otp(key) + if result: + response = jsonify({"status": "ok", "data": {"uid": str(participant.uuid), "twoFactor": True}}) + message = gettext("Please use this OTP code: %(code)s", code=result) + # send_message.delay(g.event.id, message, participant.primary_phone) + print(f"OTP message: {message}. To: {participant.primary_phone}") + return response + else: + response = jsonify({"status": "error", "message": gettext("Please contact the administrator")}) + return response + + +@csrf.exempt +def resend_otp(): + request_data = request.json + uid = request_data.get("uid") + participant: Participant | None = Participant.query.where(Participant.uuid == uid).one_or_none() + if not participant: + response = jsonify({"status": "error", "message": "Not found"}) + response.status_code = HTTPStatus.NOT_FOUND + return response + + return _process_2fa_login(participant) + + +@csrf.exempt +def verify_otp(): + request_data = request.json + uid = request_data.get("uid") + entered_otp = request_data.get("otp") + client = red._redis_client + if client: + key = f"2fa:{uid}" + result: bytes = client.get(key) + if result is None: + response = jsonify({"status": "error", "message": gettext("Verification failed.")}) + response.status_code = HTTPStatus.FORBIDDEN + return response + saved_otp = result.decode() + if entered_otp == saved_otp: + participant = Participant.query.filter(Participant.uuid == uid).first() + if not participant: + response = jsonify({"status": "error", "message": gettext("Verification failed.")}) + return response + return _process_login(participant) + else: + response = jsonify({"status": "error", "message": gettext("Verification failed.")}) + response.status_code = HTTPStatus.FORBIDDEN + return response + else: + response = jsonify({"status": "error", "message": gettext("Please contact the administrator")}) + return response + + @csrf.exempt @jwt_required() def logout(): @@ -320,7 +412,7 @@ def get_participant_count(**kwargs): participants = participants.join( Location, Participant.location_id == Location.id ).filter(Location.location_type_id == level_id) - + if role_id: participants = participants.join( ParticipantRole, Participant.role_id == ParticipantRole.id diff --git a/apollo/participants/views_participants.py b/apollo/participants/views_participants.py index 9fd2935c2..ca3154714 100644 --- a/apollo/participants/views_participants.py +++ b/apollo/participants/views_participants.py @@ -54,6 +54,8 @@ bp.add_url_rule("/api/participants/login", view_func=api_views.login, methods=["POST"]) bp.add_url_rule("/api/participants/logout", view_func=api_views.logout, methods=["DELETE"]) +bp.add_url_rule("/api/participants/resend", view_func=api_views.resend_otp, methods=["POST"]) +bp.add_url_rule("/api/participants/verify", view_func=api_views.verify_otp, methods=["POST"]) bp.add_url_rule( "/api/participants/forms", view_func=api_views.get_forms, diff --git a/apollo/settings.py b/apollo/settings.py index fb90bdbbf..fcc95da0f 100644 --- a/apollo/settings.py +++ b/apollo/settings.py @@ -222,6 +222,7 @@ PERMANENT_SESSION_LIFETIME = timedelta(hours=1) SECURITY_USER_IDENTITY_ATTRIBUTES = [{"username": {"mapper": uia_username_mapper, "case_insensitive": True}}] APOLLO_FIELD_COORDINATOR_EMAIL = config("APOLLO_FIELD_COORDINATOR_EMAIL", default="fc@example.com") +SECURITY_TWO_FACTOR = config("SECURITY_TWO_FACTOR", cast=bool, default=False) AWS_ACCESS_KEY_ID = config("AWS_ACCESS_KEY_ID") if ATTACHMENTS_USE_S3 else None AWS_SECRET_ACCESS_KEY = config("AWS_SECRET_ACCESS_KEY") if ATTACHMENTS_USE_S3 else None @@ -240,3 +241,6 @@ JWT_ERROR_MESSAGE_KEY = "message" JWT_COOKIE_SECURE = SESSION_COOKIE_SECURE JWT_ACCESS_COOKIE_PATH = "/api" + +# PWA 2FA +PWA_TWO_FACTOR = config("PWA_TWO_FACTOR", cast=bool, default=SECURITY_TWO_FACTOR) From 7ecf6adfc83d0b9da184a8d5eb38b7a416f71426 Mon Sep 17 00:00:00 2001 From: Odumosu Dipo Date: Mon, 21 Oct 2024 20:39:17 +0100 Subject: [PATCH 02/10] feat: remove debugging statement --- apollo/participants/api/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apollo/participants/api/views.py b/apollo/participants/api/views.py index 45ee75f11..106d49766 100644 --- a/apollo/participants/api/views.py +++ b/apollo/participants/api/views.py @@ -236,8 +236,7 @@ def _process_2fa_login(participant: Participant): if result: response = jsonify({"status": "ok", "data": {"uid": str(participant.uuid), "twoFactor": True}}) message = gettext("Please use this OTP code: %(code)s", code=result) - # send_message.delay(g.event.id, message, participant.primary_phone) - print(f"OTP message: {message}. To: {participant.primary_phone}") + send_message.delay(g.event.id, message, participant.primary_phone) return response else: response = jsonify({"status": "error", "message": gettext("Please contact the administrator")}) From 7c3cb543a8c9b65f5f777282b118aa069dfaef8c Mon Sep 17 00:00:00 2001 From: Odumosu Dipo Date: Mon, 21 Oct 2024 23:44:45 +0100 Subject: [PATCH 03/10] feat: first version of PWA 2fa frontend --- apollo/pwa/static/js/client.js | 24 +++++ apollo/pwa/static/js/serviceworker.js | 1 + .../static/vendor/js-cookie/js.cookie.min.js | 2 + .../vendor/tiny-cookie/tiny-cookie.min.js | 1 - apollo/pwa/templates/pwa/index.html | 96 +++++++++++++++++-- 5 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 apollo/pwa/static/vendor/js-cookie/js.cookie.min.js delete mode 100644 apollo/pwa/static/vendor/tiny-cookie/tiny-cookie.min.js diff --git a/apollo/pwa/static/js/client.js b/apollo/pwa/static/js/client.js index 1224aa3c0..16bfc6507 100644 --- a/apollo/pwa/static/js/client.js +++ b/apollo/pwa/static/js/client.js @@ -24,6 +24,30 @@ class APIClient { }).then(this._getResult); }; + verify = function (uid, otp) { + return fetch(this.endpoints.verify, { + body: JSON.stringify({ + uid: uid, + otp: otp + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST' + }).then(this._getResult); + }; + + resend = function (uid) { + return fetch(this.endpoints.resend, { + body: JSON.stringify({ + uid: uid + }), + headers: { + 'Content-Type': 'application/json' + } + }).then(this._getResult); + } + submit = function (formData, csrf_token) { return fetch(this.endpoints.submit, { body: formData, diff --git a/apollo/pwa/static/js/serviceworker.js b/apollo/pwa/static/js/serviceworker.js index cf33a03f9..fe17a5b10 100644 --- a/apollo/pwa/static/js/serviceworker.js +++ b/apollo/pwa/static/js/serviceworker.js @@ -18,6 +18,7 @@ const CACHED_URLS = [ '/pwa/static/vendor/dexie/dexie.min.js', '/pwa/static/vendor/fast-copy/fast-copy.min.js', '/pwa/static/vendor/image-blob-reduce/image-blob-reduce.min.js', + '/pwa/static/vendor/js-cookie/js.cookie.min.js', '/pwa/static/vendor/luxon/luxon.min.js', '/pwa/static/vendor/notiflix/notiflix-2.7.0.min.css', '/pwa/static/vendor/notiflix/notiflix-2.7.0.min.js', diff --git a/apollo/pwa/static/vendor/js-cookie/js.cookie.min.js b/apollo/pwa/static/vendor/js-cookie/js.cookie.min.js new file mode 100644 index 000000000..962d48d0e --- /dev/null +++ b/apollo/pwa/static/vendor/js-cookie/js.cookie.min.js @@ -0,0 +1,2 @@ +/*! js-cookie v3.0.5 | MIT */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self,function(){var n=e.Cookies,o=e.Cookies=t();o.noConflict=function(){return e.Cookies=n,o}}())}(this,(function(){"use strict";function e(e){for(var t=1;t{{ _('Login') }} +