diff --git a/apollo/core.py b/apollo/core.py index 6a99f157c..2da56cc32 100644 --- a/apollo/core.py +++ b/apollo/core.py @@ -16,6 +16,8 @@ except ImportError: fdt_available = False from flask_jwt_extended import JWTManager +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from flask_mail import Mail from flask_menu import Menu from flask_migrate import Migrate @@ -41,6 +43,7 @@ def index(self): cors = CORS() db = SQLAlchemy(session_options={"expire_on_commit": False}) jwt_manager = JWTManager() +limiter = Limiter(get_remote_address) mail = Mail() menu = Menu() metrics = PrometheusMetrics.for_app_factory(defaults_prefix=NO_PREFIX) diff --git a/apollo/factory.py b/apollo/factory.py index 4deed34df..53fbd52d0 100644 --- a/apollo/factory.py +++ b/apollo/factory.py @@ -24,6 +24,7 @@ debug_toolbar, fdt_available, jwt_manager, + limiter, mail, menu, metrics, @@ -127,6 +128,7 @@ def _before_send(event, hint): cors.init_app(app) db.init_app(app) jwt_manager.init_app(app) + limiter.init_app(app) migrate.init_app(app, db) mail.init_app(app) red.init_app(app) diff --git a/apollo/participants/api/views.py b/apollo/participants/api/views.py index c1f96a408..4e03a6e39 100644 --- a/apollo/participants/api/views.py +++ b/apollo/participants/api/views.py @@ -1,12 +1,20 @@ # -*- coding: utf-8 -*- +import random +import string from http import HTTPStatus -from flask import g, jsonify, request +from flask import g, jsonify, request, session 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 passlib import totp from sqlalchemy import and_, bindparam, func, or_, text, true from sqlalchemy.orm import aliased from sqlalchemy.orm.exc import NoResultFound @@ -15,18 +23,35 @@ from apollo import settings from apollo.api.common import BaseListResource from apollo.api.decorators import protect -from apollo.core import csrf, red +from apollo.core import csrf, limiter, red from apollo.deployments.models import Event 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 + +if settings.PWA_TWO_FACTOR: + TOTP_INSTANCE = totp.TOTP.using( + issuer=settings.SECURITY_TOTP_ISSUER, + secrets=settings.SECURITY_TOTP_SECRETS + ) +else: + TOTP_INSTANCE = None + @marshal_with(ParticipantSchema) @use_kwargs({'event_id': fields.Int()}, location='query') @@ -158,6 +183,14 @@ def login(): return response + session.set("uid", str(participant.uuid)) + 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 +207,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 +227,60 @@ def login(): return resp +def generate_otp(participant: Participant) -> str: + if not participant.totp_secret: + participant.totp_secret = TOTP_INSTANCE.new().to_json(encrypt=True) + participant.save() + result = TOTP_INSTANCE.from_source(participant.totp_secret).generate() + return result.token + + +def _process_2fa_login(participant: Participant): + key = f"2fa:{participant.uuid}" + result = generate_otp(key) + if result: + response = jsonify({"status": "ok", "data": {"uid": str(participant.uuid), "twoFactor": True}}) + message = gettext("Your Apollo verification code is: %(totp_code)s", totp_code=result) + send_message.delay(g.event.id, message, participant.primary_phone) + return response + else: + response = jsonify({"status": "error", "message": gettext("Please contact the administrator")}) + return response + + +@csrf.exempt +def resend_otp(): + uid = session.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 = session.get("uid") + entered_otp = request_data.get("otp") + participant: Participant | None = Participant.query.where(Participant.uuid == uid).one_or_none() + if not participant or not participant.totp_secret: + response = jsonify({"status": "error", "message": "Not found"}) + response.status_code = HTTPStatus.NOT_FOUND + return response + + try: + TOTP_INSTANCE.verify(entered_otp, participant.totp_secret) + except Exception: + response = jsonify({"status": "error", "message": gettext("Verification failed.")}) + response.status_code = HTTPStatus.FORBIDDEN + return response + + return _process_login(participant) + + @csrf.exempt @jwt_required() def logout(): @@ -320,7 +407,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/models.py b/apollo/participants/models.py index 24f1683b2..063c099a6 100644 --- a/apollo/participants/models.py +++ b/apollo/participants/models.py @@ -205,6 +205,7 @@ class Participant(BaseModel): password = db.Column(db.String) extra_data = db.Column(JSONB) locale = db.Column(db.String) + totp_secret = db.Column(db.String, nullable=True) full_name = translation_hybrid(full_name_translations) first_name = translation_hybrid(first_name_translations) 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/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..dc24595d7 100644 --- a/apollo/pwa/static/js/serviceworker.js +++ b/apollo/pwa/static/js/serviceworker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'apollo-cache-static-v11'; +const CACHE_NAME = 'apollo-cache-static-v12'; const CACHED_URLS = [ '/pwa/', @@ -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
- + +
@@ -95,6 +96,33 @@
{{ _('Login') }}
+