commit d0cdf6c38ec5c9109668cb60230e17c532e3b5e2 Author: mahdavi Date: Thu Nov 14 12:32:11 2019 +0330 init accounts service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c9917a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.python-version +.idea +media/* +delme*.py +Pipfile.lock +static +*.pyc +.env diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..0e0c4c9 --- /dev/null +++ b/Pipfile @@ -0,0 +1,23 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] + +[packages] +django = "*" +django-oauth-toolkit = "*" +djangorestframework = "*" +markdown = "*" +django-filter = "*" +django-cors-middleware = "*" +pillow = "*" +django-model-utils = "*" +gunicorn = "*" +mysqlclient = "*" +django-mysql = "*" +python-decouple = "*" + +[requires] +python_version = "3.7" diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/settings.py b/accounts/settings.py new file mode 100644 index 0000000..c579183 --- /dev/null +++ b/accounts/settings.py @@ -0,0 +1,176 @@ +""" +Django settings for accounts project. + +Generated by 'django-admin startproject' using Django 2.2. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.2/ref/settings/ +""" + +import os + +from decouple import config + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'tm9-y*5r=n028ii8iny#u1p3&mdjqf-#kj!z^fg%sbussj@r+o' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # 'dot_restrict_scopes', + 'oauth2_provider', + 'rest_framework', + 'corsheaders', + 'apps.users', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'corsheaders.middleware.CorsMiddleware', +] + +# Tell Django OAuth Toolkit to use the RestrictedApplication model +# OAUTH2_PROVIDER_APPLICATION_MODEL = 'dot_restrict_scopes.RestrictedApplication' +# OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' +# OAUTH2_PROVIDER_GRANT_MODEL = 'oauth2_provider.Grant' +# OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' + + +OAUTH2_PROVIDER = { + # Tell Django OAuth Toolkit to use the scopes backend + # 'SCOPES_BACKEND_CLASS': 'dot_restrict_scopes.scopes.RestrictApplicationScopes', + # this is the list of available scopes + 'SCOPES': {'read': 'Read scope', + 'write': 'Write scope', + 'groups': 'Access to your groups', + 'external': 'Access resource from external'}, + 'OAUTH2_VALIDATOR_CLASS': 'utils.MultiGatewayOAuth2Validator' +} + +# Tell dot-restrict-scopes which scopes backend it is wrapping +# NOTE: oauth2_provider.scopes.SettingsScopes is the default, so this is not +# strictly necessary if you want to use scopes from settings +DOT_RESTRICT_SCOPES = { + 'WRAPPED_SCOPES_BACKEND_CLASS': 'oauth2_provider.scopes.SettingsScopes', +} + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 10 +} + +ROOT_URLCONF = 'accounts.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'accounts.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/2.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': config('DB_NAME'), + 'USER': config('DB_USER'), + 'PASSWORD': config('DB_PASSWORD'), + 'HOST': config('DB_HOST', '127.0.0.1'), + 'PORT': config('DB_PORT', ''), + } +} + +# Password validation +# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/2.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.2/howto/static-files/ + + +AUTH_USER_MODEL = 'users.User' +CORS_ORIGIN_ALLOW_ALL = True + +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +MEDIA_URL = "/media/" + +STATIC_ROOT = os.path.join(BASE_DIR, 'static') +STATIC_URL = '/static/' diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..a93e3b3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,38 @@ +"""accounts URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/2.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.conf import settings +from django.conf.urls.static import static +from django.urls import path, include +from django.contrib import admin + +from apps.users.views import UserListView, UserDetailView, AccountView, RequestOTPView, ChangePasswordView +from django.contrib.auth import urls as auth_urls + +# Setup the URLs and include login URLs for the browsable API. +urlpatterns = [ + path('admin/', admin.site.urls), + path('accounts/', include(auth_urls)), + path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')), + + path('users/', UserListView.as_view()), + path('users//', UserDetailView.as_view(), name='user_detail'), + path('account/', AccountView.as_view(), name='account'), + path('request_otp/', RequestOTPView.as_view(), name='register'), + path('change_password/', ChangePasswordView.as_view(), name='change_password'), +] + +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/accounts/wsgi.py b/accounts/wsgi.py new file mode 100644 index 0000000..d4e6b0e --- /dev/null +++ b/accounts/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for accounts project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') + +application = get_wsgi_application() diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..058bc7a --- /dev/null +++ b/apps/users/admin.py @@ -0,0 +1,32 @@ +from django.contrib import admin +from .models import User + + +class UserAdmin(admin.ModelAdmin): + fields = [ + 'name', + 'avatar', + 'is_active', + 'first_name', + 'last_name', + 'username', + 'email', + 'phone_number', + 'code', + 'password', + 'otp', + 'is_staff', + 'is_superuser', + 'groups', + 'user_permissions', + 'last_login', + 'date_joined', + 'last_update', + 'otp_expire', + 'otp_try', + 'balance', + ] + readonly_fields = ['last_update'] + + +admin.site.register(User, UserAdmin) diff --git a/apps/users/apps.py b/apps/users/apps.py new file mode 100644 index 0000000..4ce1fab --- /dev/null +++ b/apps/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'users' diff --git a/apps/users/constans.py b/apps/users/constans.py new file mode 100644 index 0000000..104853b --- /dev/null +++ b/apps/users/constans.py @@ -0,0 +1,2 @@ +MAX_OTP_TRY = 3 +DEVELOPMENT_PHONE_NUMBERS = ['+989106853582'] \ No newline at end of file diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..786af23 --- /dev/null +++ b/apps/users/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated by Django 2.2.7 on 2019-11-14 08:47 + +import apps.users.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('name', models.CharField(blank=True, max_length=30, verbose_name='name')), + ('username', models.CharField(blank=True, error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, null=True, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('phone_number', models.CharField(blank=True, max_length=30, null=True, unique=True, verbose_name='phone number')), + ('otp', models.CharField(blank=True, max_length=6, null=True, verbose_name='otp')), + ('otp_expire', models.DateTimeField(blank=True, null=True, verbose_name='otp expire')), + ('otp_try', models.IntegerField(blank=True, default=0, null=True, verbose_name='otp try')), + ('last_checkout_request', models.DateTimeField(blank=True, max_length=30, null=True, verbose_name='otp expire')), + ('balance', models.IntegerField(default=0, verbose_name='balance')), + ('iban', models.CharField(blank=True, max_length=30, null=True)), + ('iban_verified', models.BooleanField(null=True)), + ('avatar', models.ImageField(blank=True, null=True, upload_to='avatars')), + ('code', models.IntegerField(db_index=True, null=True, unique=True, verbose_name='code')), + ('last_update', models.DateTimeField(auto_now=True, max_length=30, null=True, verbose_name='last update')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', apps.users.models.UserManager()), + ], + ), + ] diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/models.py b/apps/users/models.py new file mode 100644 index 0000000..9b515e3 --- /dev/null +++ b/apps/users/models.py @@ -0,0 +1,165 @@ +import random +from datetime import timedelta + +from django.contrib.auth.base_user import BaseUserManager +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.validators import UnicodeUsernameValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import APIException + +from apps.users.constans import MAX_OTP_TRY + + +class UserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, phone_number=None, username=None, email=None, password=None, **extra_fields): + if not phone_number and not email: + raise ValueError('The given phone_number or email must be set') + + email = self.normalize_email(email) + username = self.model.normalize_username(username) + # TODO: validate phone number + user = self.model(phone_number=phone_number, username=username, email=email, **extra_fields) + user.set_password(password) + user.set_otp(with_save=False) + user.set_code() + user.date_joined = timezone.now() + user.save(using=self._db) + + return user + + def create_user(self, phone_number=None, username=None, email=None, password=None, **extra_fields): + + extra_fields.setdefault('is_staff', False) + extra_fields.setdefault('is_superuser', False) + + # TODO create OTP + return self._create_user(phone_number=phone_number, + username=username, + email=email, + password=password, + **extra_fields) + + def create_superuser(self, username, email, password, **extra_fields): + if not username: + raise ValueError('The given username must be set') + + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError('Superuser must have is_staff=True.') + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser must have is_superuser=True.') + + return self._create_user(username=username, email=email, password=password, **extra_fields) + + +class User(AbstractUser): + # id + # first_name + # last_name + # email + # is_staff + # is_active + # password + + name = models.CharField(_('name'), max_length=30, blank=True) + + username_validator = UnicodeUsernameValidator() + username = models.CharField( + _('username'), + max_length=150, + unique=True, + help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'), + validators=[username_validator], + error_messages={ + 'unique': _("A user with that username already exists."), + }, + blank=True, + null=True + ) + phone_number = models.CharField(_('phone number'), max_length=30, blank=True, null=True, unique=True) + otp = models.CharField(_('otp'), max_length=6, blank=True, null=True) + otp_expire = models.DateTimeField(_('otp expire'), blank=True, null=True) + otp_try = models.IntegerField(_('otp try'), blank=True, null=True, default=0) + + last_checkout_request = models.DateTimeField(_('otp expire'), max_length=30, blank=True, null=True) + balance = models.IntegerField(_('balance'), default=0) + + iban = models.CharField(null=True, max_length=30, blank=True) + iban_verified = models.BooleanField(null=True) + + avatar = models.ImageField(upload_to='avatars', null=True, blank=True) + code = models.IntegerField(_('code'), unique=True, null=True, db_index=True) # unique random number + + last_update = models.DateTimeField(_('last update'), max_length=30, blank=True, null=True, auto_now=True) + # last_login + # date_joined + + objects = UserManager() + + def set_otp(self, with_save=True): + self.otp = '12345' + # if self.phone_number in DEVELOPMENT_PHONE_NUMBERS: + # self.otp = '12345' + # else: + # self.otp = ''.join(random.choice('0123456789') for _ in range(5)) + self.otp_expire = timezone.now() + timedelta(minutes=5) + self.otp_try = 0 + if with_save: + self.save() + + def set_code(self): + for code in random.sample(range(10000, 100000), 90000): + try: + code = str(code) + self.code = code + return self.save() + except: + pass + + raise APIException('system error') + + def otp_is_valid(self): + return bool(self.otp and timezone.now() <= self.otp_expire) + + def check_otp(self, otp): + if self.otp_try <= MAX_OTP_TRY: + result = otp and self.otp == otp and self.otp_is_valid() + if result: + self.otp = None + self.date_joined = timezone.now() + else: + self.otp_try += 1 + + else: + self.otp = None + result = False + + self.save() + return result + + def check_auth(self, gateway, value): + result = False + if gateway == 'otp': + result = self.check_otp(value) + + elif gateway == 'password': + result = self.check_password(value) + + if result: + self.last_login = timezone.now() + self.save() + + return result + + def send_otp(self): + # TODO: some action to sent OTP + return True + + def __str__(self): + return f"{self.code} - {self.username}" diff --git a/apps/users/serializers.py b/apps/users/serializers.py new file mode 100644 index 0000000..ad83296 --- /dev/null +++ b/apps/users/serializers.py @@ -0,0 +1,82 @@ +from django.contrib.auth.models import Group +from rest_framework import serializers + +from apps.users.models import User +from django.utils import timezone + +# public data +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('avatar', 'name', 'username', 'email', "first_name", "last_name", "code") + read_only_fields = ['avatar', 'name', 'username', 'email', 'first_name', 'last_name'] + + def to_internal_value(self, data): + code = data.get('code') + if code: + return User.objects.get(code=code) + + +class AccountSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ( + "code", + 'username', + 'email', + 'phone_number', + "first_name", + "last_name", + 'name', + 'balance', + 'avatar', + 'iban', + 'iban_verified', + ) + read_only_fields = ['code', 'balance', 'email', 'phone_number', 'iban_verified'] + + +class RequestOTPSerializer(serializers.ModelSerializer): + phone_number = serializers.CharField(required=True) + ttl = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'phone_number', + 'otp_expire', + 'ttl' + ) + + read_only_fields = ['otp_expire', 'ttl'] + write_only_fields = [] + + def save(self, **kwargs): + validated_data = self.validated_data + user = User.objects.filter(phone_number=validated_data['phone_number']).first() + if not user: + user = User.objects.create_user(**validated_data) + + if not user.otp_is_valid(): + user.set_otp(with_save=True) + + user.send_otp() + + self.instance = user + return user + + def get_ttl(self, obj: User): + if obj.otp_expire > timezone.now(): + ttl = obj.otp_expire - timezone.now() + result = ttl.total_seconds() + + else: + result = 0 + + return result + + +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(required=True) + old_password_gateway = serializers.CharField(default='password') + new_password = serializers.CharField(required=True) diff --git a/apps/users/tests.py b/apps/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/users/views.py b/apps/users/views.py new file mode 100644 index 0000000..0a50a8f --- /dev/null +++ b/apps/users/views.py @@ -0,0 +1,69 @@ +from django.utils import timezone +from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope, IsAuthenticatedOrTokenHasScope +from rest_framework import generics, permissions, status +from rest_framework.response import Response + +from apps.users.models import User +from apps.users.serializers import UserSerializer, AccountSerializer, RequestOTPSerializer, ChangePasswordSerializer + + +class UserListView(generics.ListAPIView): + permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope] + queryset = User.objects.all() + serializer_class = UserSerializer + required_scopes = [] + + +class UserDetailView(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope] + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'code' + required_scopes = [] + + +class AccountView(generics.RetrieveUpdateAPIView): + permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope] + serializer_class = AccountSerializer + required_scopes = [] + + def get_object(self): + return self.request.user + + def perform_update(self, serializer): + serializer.save(last_update=timezone.now()) + + +class RequestOTPView(generics.CreateAPIView): + permission_classes = [] + serializer_class = RequestOTPSerializer + required_scopes = [] + + +class ChangePasswordView(generics.UpdateAPIView): + permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope] + serializer_class = ChangePasswordSerializer + required_scopes = [] + model = User + + def get_object(self, queryset=None): + obj = self.request.user + return obj + + def update(self, request, *args, **kwargs): + self.object = self.get_object() + serializer = self.get_serializer(data=request.data) + + if serializer.is_valid(): + gateway = serializer.data.get("old_password_gateway") + old_password = serializer.data.get("old_password") + new_password = serializer.data.get("new_password") + if not self.object.check_auth(gateway, old_password): + return Response({"old_password": ["Wrong password/otp."]}, status=status.HTTP_400_BAD_REQUEST) + + self.object.set_password(new_password) + self.object.last_update = timezone.now() + self.object.save() + return Response({"state": 'success'}, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..5a6a29a --- /dev/null +++ b/deploy.sh @@ -0,0 +1 @@ +# Deployment script \ No newline at end of file diff --git a/dot_restrict_scopes/__init__.py b/dot_restrict_scopes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dot_restrict_scopes/admin.py b/dot_restrict_scopes/admin.py new file mode 100644 index 0000000..f52fc16 --- /dev/null +++ b/dot_restrict_scopes/admin.py @@ -0,0 +1,25 @@ +""" +Django admin configuration for the dot-restrict-scopes package. +""" + +from django.contrib import admin +from django.contrib.admin.sites import NotRegistered + +from oauth2_provider.admin import ApplicationAdmin + +from .models import RestrictedApplication +from .forms import RestrictedApplicationForm + + +# The restricted application is registered by Django OAuth Toolkit, but we want +# to provide our own admin that uses our form +try: + admin.site.unregister(RestrictedApplication) +except NotRegistered: + pass + +@admin.register(RestrictedApplication) +class RestrictedApplicationAdmin(ApplicationAdmin): + form = RestrictedApplicationForm + +# admin.site.register(RestrictedApplication, RestrictedApplicationAdmin) diff --git a/dot_restrict_scopes/apps.py b/dot_restrict_scopes/apps.py new file mode 100644 index 0000000..1f8ab7e --- /dev/null +++ b/dot_restrict_scopes/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DotRestrictScopesConfig(AppConfig): + name = 'dot_restrict_scopes' diff --git a/dot_restrict_scopes/forms.py b/dot_restrict_scopes/forms.py new file mode 100644 index 0000000..aad5687 --- /dev/null +++ b/dot_restrict_scopes/forms.py @@ -0,0 +1,51 @@ +""" +Django forms for use with the dot-restrict-scopes package. +""" + +from django import forms + +from oauth2_provider.scopes import get_scopes_backend + + +class DelimitedListField(forms.MultipleChoiceField): + """ + Django form field that allows for the use of list widgets with a text field + containing a delimited list. + """ + delimiter = ',' + + def __init__(self, delimiter = None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.delimiter = delimiter or self.delimiter + + def prepare_value(self, value): + # If the value is already a list or tuple, just use it as-is + if isinstance(value, (list, tuple)): return value + # Otherwise, prepare the value by splitting on the delimiter, trimming + # leading and trailing whitespace and excluding empty values + return [p.strip() for p in value.split(self.delimiter) if p.strip()] + + def clean(self, value): + # Let the parent clean the value first, then join the result using the + # specified delimiter + return self.delimiter.join(super().clean(value)) + + +class RestrictedApplicationForm(forms.ModelForm): + """ + Form for creating or updating a restricted application. + """ + # allowed_scope is a space-delimited list, but we want to present + # a selection of valid scopes with checkboxes + allowed_scope = DelimitedListField( + label = 'Allowed scopes', + # The choices and initial values are callables, because the scopes might + # not be available at import type, e.g. if coming from the database + choices = lambda: get_scopes_backend().get_all_scopes().items(), + initial = lambda: get_scopes_backend().get_default_scopes(), + delimiter = ' ', + widget = forms.CheckboxSelectMultiple + ) + + class Meta: + exclude = () diff --git a/dot_restrict_scopes/migrations/0001_initial.py b/dot_restrict_scopes/migrations/0001_initial.py new file mode 100644 index 0000000..53ae132 --- /dev/null +++ b/dot_restrict_scopes/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-01 15:44 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import oauth2_provider.generators +import oauth2_provider.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RestrictedApplication', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), + ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), + ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), + ('name', models.CharField(blank=True, max_length=255)), + ('skip_authorization', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('allowed_scope', models.TextField(blank=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='dot_restrict_scopes_restrictedapplication', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/dot_restrict_scopes/migrations/__init__.py b/dot_restrict_scopes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dot_restrict_scopes/models.py b/dot_restrict_scopes/models.py new file mode 100644 index 0000000..689d111 --- /dev/null +++ b/dot_restrict_scopes/models.py @@ -0,0 +1,25 @@ +""" +Django models for the dot-restrict-scopes package. +""" + +from django.db import models + +from oauth2_provider.models import AbstractApplication +from oauth2_provider.scopes import get_scopes_backend + + +class RestrictedApplication(AbstractApplication): + """ + Application model for use with Django OAuth Toolkit that allows the scopes + available to an application to be restricted on a per-application basis. + """ + allowed_scope = models.TextField(blank = True) + + @property + def allowed_scopes(self): + """ + Returns the set of allowed scope names for this application. + """ + all_scopes = set(get_scopes_backend().get_all_scopes().keys()) + app_scopes = set(self.allowed_scope.split()) + return app_scopes.intersection(all_scopes) diff --git a/dot_restrict_scopes/scopes.py b/dot_restrict_scopes/scopes.py new file mode 100644 index 0000000..53f57ac --- /dev/null +++ b/dot_restrict_scopes/scopes.py @@ -0,0 +1,43 @@ +""" +Django OAuth Toolkit scopes backend for the dot-restrict-scopes package. +""" + +from django.conf import settings +from django.utils import module_loading + +from oauth2_provider.scopes import BaseScopes + + +class RestrictApplicationScopes(BaseScopes): + """ + Scopes backend that wraps another backend and restricts the scopes available + to an application based on the application's ``allowed_scopes``. + """ + def __init__(self): + # Initialise the wrapped backend from settings + self._wrapped = module_loading.import_string( + getattr(settings, 'DOT_RESTRICT_SCOPES', {}).get( + 'WRAPPED_SCOPES_BACKEND_CLASS', + 'oauth2_provider.scopes.SettingsScopes' + ) + )() + + def get_all_scopes(self): + # Just return all the available scopes from the wrapped backend + return self._wrapped.get_all_scopes() + + def get_available_scopes(self, application = None, request = None, *args, **kwargs): + # Get the available scopes from the wrapped backend, then filter them + # based on the allowed_scopes of the application + scopes = self._wrapped.get_available_scopes(application, request, *args, **kwargs) + if application: + scopes = [s for s in scopes if s in application.allowed_scopes] + return scopes + + def get_default_scopes(self, application = None, request = None, *args, **kwargs): + # Get the default scopes from the wrapped backend, then filter them + # based on the allowed_scopes of the application + scopes = self._wrapped.get_default_scopes(application, request, *args, **kwargs) + if application: + scopes = [s for s in scopes if s in application.allowed_scopes] + return scopes diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..7a22e43 --- /dev/null +++ b/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/templates/base_generic.html b/templates/base_generic.html new file mode 100644 index 0000000..50381b2 --- /dev/null +++ b/templates/base_generic.html @@ -0,0 +1,12 @@ + + + + + Title + + +{% block content %} +{% endblock %} + + + \ No newline at end of file diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..f5f2bea --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,40 @@ +{% extends "base_generic.html" %} + +{% block content %} + +{% if form.errors %} +

Your username and password didn't match. Please try again.

+{% endif %} + +{% if next %} + {% if user.is_authenticated %} +

Your account doesn't have access to this page. To proceed, + please login with an account that has access.

+ {% else %} +

Please login to see this page.

+ {% endif %} +{% endif %} + +
+{% csrf_token %} + + + + + + + + + + + +
{{ form.username.label_tag }}{{ form.username }}
{{ form.password.label_tag }}{{ form.password }}
+ + + +
+ +{# Assumes you setup the password_reset view in your URLconf #} +

Lost password?

+ +{% endblock %} \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..abdb949 --- /dev/null +++ b/utils.py @@ -0,0 +1,54 @@ +import random +from time import time +from oauth2_provider.oauth2_validators import OAuth2Validator + +from django.contrib.auth import get_user_model + +USER_MODEL = get_user_model() + + +def random_code(pre_len=5, post_len=5): + code = str(time())[:pre_len] + ''.join(random.choice('0123456789') for _ in range(post_len)) + return code + + +class MultiGatewayOAuth2Validator(OAuth2Validator): # pylint: disable=w0223 + """ Primarily extend the functionality of token generation """ + + def validate_user(self, username, password, client, request, *args, **kwargs): + """ Here, you would be able to access the MOBILE/ OTP fields + which you will be sending in the request.post body. """ + # otp = request.otp + # mobile = request.mobile + # user = AppropriateModel.objects.get(otp=otp, mobile=mobile) + auth_gateway = getattr(request, 'auth_gateway', 'username:password').split(':') + user_gateway = auth_gateway[0] + pass_gateway = auth_gateway[1] + + user = None + if user_gateway == 'phone_number': + user = USER_MODEL.objects.get( + phone_number=username + ) + + elif auth_gateway == 'username': + user = USER_MODEL.objects.get( + username=username + ) + + elif user_gateway == 'email': + user = USER_MODEL.objects.get( + email=username + ) + + if user is None: + return False + + if not user.check_auth(pass_gateway, password): + return False + + if user.is_active: + request.user = user + return True + + return False