diff --git a/core/classes/otp.py b/core/classes/otp.py new file mode 100644 index 0000000..6e04028 --- /dev/null +++ b/core/classes/otp.py @@ -0,0 +1,21 @@ +class BaseOTPProvider: + '''OTP providers must be subclasses of BaseOTPProvider and implement at least validate_token().''' + + @property + def get_name(self): + return "Base OTP Provider" + + @property + def get_logo(self): + return "" + + @property + def is_active(self): + '''Returns True if the provider is properly configured and ready to use.''' + raise NotImplementedError(f"{type(self)} does not implement is_active!") + + def start_authentication(self, user): + return "Authentication started, please enter your 2FA token." + + def validate_token(self, user, token): + raise NotImplementedError(f"{type(self)} does not implement validate_token()!") \ No newline at end of file diff --git a/core/models/__init__.py b/core/models/__init__.py index f7552b8..9aa500d 100644 --- a/core/models/__init__.py +++ b/core/models/__init__.py @@ -1 +1,2 @@ -from core.models.files import * \ No newline at end of file +from core.models.files import * +from core.models.profiles import * \ No newline at end of file diff --git a/core/models/profiles.py b/core/models/profiles.py new file mode 100644 index 0000000..51d8f92 --- /dev/null +++ b/core/models/profiles.py @@ -0,0 +1,12 @@ +from polymorphic.models import PolymorphicModel +from phonenumber_field.modelfields import PhoneNumberField + +from django.db.models import OneToOneField, CASCADE, CharField +from django.contrib.auth import get_user_model + +class Profile(PolymorphicModel): + user = OneToOneField(get_user_model(), CASCADE) + mobile = PhoneNumberField(blank=True) + +class AdminProfile(Profile): + role = CharField(max_length=64) \ No newline at end of file diff --git a/core/modules/otp.py b/core/modules/otp.py new file mode 100644 index 0000000..e482cfc --- /dev/null +++ b/core/modules/otp.py @@ -0,0 +1,13 @@ +import importlib + +from django.conf import settings + +providers = [] + +for module in settings.EXPEPHALON_MODULES: + try: + moo = importlib.import_module(f"{module}.otp") + for provider in moo.OTPPROVIDERS: + providers.append(provider) + except (AttributeError, ModuleNotFoundError): + continue \ No newline at end of file diff --git a/core/modules/sms.py b/core/modules/sms.py index b9ae1dd..7aef123 100644 --- a/core/modules/sms.py +++ b/core/modules/sms.py @@ -2,9 +2,7 @@ import importlib from django.conf import settings -from core.classes.navigation import NavItem, NavSection, Navigation - -from dbsettings import getValue +from dbsettings.functions import getValue providers = [] @@ -16,7 +14,7 @@ for module in settings.EXPEPHALON_MODULES: for provider in mos.SMSPROVIDERS: providers.append(provider) if mos.CREATE: - modules_available = mos.CREATE + modules_available.append(mos.CREATE) except (AttributeError, ModuleNotFoundError): continue diff --git a/core/navigations.py b/core/navigations.py index 23ee59d..d464e80 100644 --- a/core/navigations.py +++ b/core/navigations.py @@ -91,6 +91,7 @@ administration_section = NavSection("Administration", "") user_administration_item = NavItem("Administrator Users", "fa-users-cog", "backend") brand_administration_item = NavItem("Brands", "fa-code-branch", "backend") sms_administration_item = NavItem("SMS Gateway", "fa-sms", "backend") +otp_administration_item = NavItem("Two-Factor Authentication", "fa-id-badge", "backend") backup_administration_item = NavItem("Backups", "fa-shield-alt", "backend") product_administration_item = NavItem("Products", "fa-cube", "backend") pgroup_administration_item = NavItem("Product Groups", "fa-cubes", "backend") @@ -100,6 +101,7 @@ dbsettings_item = NavItem("Database Settings", "fa-database", "dbsettings") administration_section.add_item(user_administration_item) administration_section.add_item(brand_administration_item) administration_section.add_item(sms_administration_item) +administration_section.add_item(otp_administration_item) administration_section.add_item(backup_administration_item) administration_section.add_item(product_administration_item) administration_section.add_item(pgroup_administration_item) diff --git a/expephalon/settings.py b/expephalon/settings.py index b0253e1..2d1fa9b 100644 --- a/expephalon/settings.py +++ b/expephalon/settings.py @@ -1,15 +1,3 @@ -""" -Django settings for expephalon project. - -Generated by 'django-admin startproject' using Django 3.0.4. - -For more information on this file, see -https://docs.djangoproject.com/en/3.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/3.0/ref/settings/ -""" - import os from expephalon.custom_settings import * # pylint: disable=unused-wildcard-import @@ -26,6 +14,8 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'polymorphic', + 'phonenumber_field', 'core', 'dbsettings', ] + EXPEPHALON_MODULES @@ -132,4 +122,4 @@ PASSWORD_HASHERS = [ # Media DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' -AWS_DEFAULT_ACL = None \ No newline at end of file +AWS_DEFAULT_ACL = None diff --git a/module_dependencies.md b/module_dependencies.md index 5bc96b3..77dd653 100644 --- a/module_dependencies.md +++ b/module_dependencies.md @@ -2,3 +2,7 @@ * core +### smsotp + +* core +* any SMS provider (e.g. playsms) \ No newline at end of file diff --git a/playsms/models.py b/playsms/models.py index c4d7d45..a4cbe74 100644 --- a/playsms/models.py +++ b/playsms/models.py @@ -25,11 +25,11 @@ class PlaySMSServer(Model, BaseSMSProvider): @staticmethod def getError(status): - if status["error_message"]: - return status["error_message"] + if status["error_string"]: + return status["error_string"] try: - if status["data"]["error"]: - return status["data"]["error"] + if int(status["data"][0]["error"]): + return int(status["data"][0]["error"]) finally: return @@ -43,11 +43,13 @@ class PlaySMSServer(Model, BaseSMSProvider): if isinstance(recipients, list): recipients = ",".join(recipients) - url = 'http%s://%s/index.php?app=ws&u="%s"&h="%s"&op=pv&to=%s&msg=%s' % ("s" if self.https else "", self.host, self.username, self.token, recipients, quote_plus(message)) + url = 'http%s://%s/index.php?app=ws&u=%s&h=%s&op=pv&to=%s&msg=%s' % ("s" if self.https else "", self.host, self.username, self.token, recipients, quote_plus(message)) + print(url) response = urlopen(url) status = json.loads(response.read().decode()) + print(status) error = PlaySMSServer.getError(status) diff --git a/playsms/sms.py b/playsms/sms.py index e69de29..846e4ad 100644 --- a/playsms/sms.py +++ b/playsms/sms.py @@ -0,0 +1,3 @@ +from playsms.models import PlaySMSServer + +SMSPROVIDERS = list(PlaySMSServer.objects.all()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9b77834..445bc44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ mysqlclient django-storages boto3 Pillow +django-polymorphic +django-phonenumber-field[phonenumbers] diff --git a/smsotp/__init__.py b/smsotp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smsotp/admin.py b/smsotp/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/smsotp/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/smsotp/apps.py b/smsotp/apps.py new file mode 100644 index 0000000..1752306 --- /dev/null +++ b/smsotp/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SmsotpConfig(AppConfig): + name = 'smsotp' diff --git a/smsotp/helpers.py b/smsotp/helpers.py new file mode 100644 index 0000000..1a85980 --- /dev/null +++ b/smsotp/helpers.py @@ -0,0 +1,6 @@ +from dbsettings.functions import getValue + +import random + +def generate_token(len=getValue("smsotp.length", 6), chars=getValue("smsotp.chars", "0123456789")): + return "".join(random.choices(chars, k=int(len))) \ No newline at end of file diff --git a/smsotp/models.py b/smsotp/models.py new file mode 100644 index 0000000..cfbcb88 --- /dev/null +++ b/smsotp/models.py @@ -0,0 +1,26 @@ +from django.db.models import Model, CharField, ForeignKey, CASCADE, DateTimeField +from django.contrib.auth import get_user_model + +from dbsettings.functions import getValue + +from smsotp.helpers import generate_token +from core.modules.sms import get_default_sms_provider + +# Create your models here. + +class OTPToken(Model): + token = CharField(max_length=32, default=generate_token) + user = ForeignKey(get_user_model(), CASCADE) + creation = DateTimeField(auto_now_add=True) + + def send_sms(self, text=getValue("smsotp.text", "Your authentication token is:"), number=None): + if "_TOKEN_" in text: + text = text.replace("_TOKEN_", self.token) + else: + text = f"{text} {self.token}" + + number = number or self.user.profile.mobile + + provider = get_default_sms_provider() + + provider.sendSMS(number, text) \ No newline at end of file diff --git a/smsotp/otp.py b/smsotp/otp.py new file mode 100644 index 0000000..6bfdf0e --- /dev/null +++ b/smsotp/otp.py @@ -0,0 +1,40 @@ +from core.classes.otp import BaseOTPProvider +from smsotp.models import OTPToken +from core.modules.sms import get_default_sms_provider + +from dbsettings.functions import getValue + +from django.utils import timezone + +class SMSOTP(BaseOTPProvider): + @property + def get_name(self): + return "SMS OTP" + + def create_token(self, user): + token = OTPToken.objects.create(user=user) + try: + token.send_sms() + return True + except: + return False + + @property + def is_active(self): + return bool(get_default_sms_provider()) + + def start_authentication(self, user): + if self.create_token(user): + return "We have sent you an SMS containing your authentication token." + else: + return "An error has occurred, please try again later or contact the administrator." + + def validate_token(self, user, token): + try: + max_age = timezone.now() - timezone.timedelta(seconds=int(getValue("smsotp.max_age", "300"))) + OTPToken.objects.get(user=user, token=token, creation__gte=max_age).delete() + return True + except OTPToken.DoesNotExist: + return False + +OTPPROVIDERS = [SMSOTP] \ No newline at end of file diff --git a/smsotp/tests.py b/smsotp/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/smsotp/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/smsotp/views.py b/smsotp/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/smsotp/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/templates/backend/login.html b/templates/backend/login.html new file mode 100644 index 0000000..0a1eff0 --- /dev/null +++ b/templates/backend/login.html @@ -0,0 +1,26 @@ + + + + + + + + Sign in + + + +
+

Sign in

+ {% csrf_token %} +
+ + + Sign in +

Forgot Password?

+ + +
+ + + + \ No newline at end of file