version 0.0.5.1: added integration with facebook, refactored a lot

This commit is contained in:
Anatoly 2019-08-09 09:56:16 +03:00
parent 2101a5ae5e
commit 22f2de94f2
35 changed files with 467 additions and 25 deletions

View File

@ -19,6 +19,11 @@ class UserAdmin(BaseUserAdmin):
(None, {'fields': ('email', 'password',)}),
(_('Personal info'), {
'fields': ('username', 'first_name', 'last_name', )}),
(_('Subscription'), {
'fields': (
'newsletter',
)
}),
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
(_('Permissions'), {
'fields': (

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.4 on 2019-08-08 08:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='newsletter',
field=models.NullBooleanField(default=True),
),
]

View File

@ -10,6 +10,18 @@ class UserManager(BaseUserManager):
use_in_migrations = False
def make(self, username: str, email: str,
password: str, newsletter: bool) -> object:
"""Register new user"""
obj = self.model(
email=email,
username=username,
password=password,
newsletter=newsletter
)
obj.save()
return obj
class UserQuerySet(models.QuerySet):
"""Extended queryset for User model."""
@ -23,6 +35,7 @@ class User(ImageMixin, AbstractUser):
"""Base user model."""
email = models.EmailField(_('email address'), blank=True,
null=True, default=None)
newsletter = models.NullBooleanField(default=True)
EMAIL_FIELD = 'email'
USERNAME_FIELD = 'username'

View File

View File

@ -1,7 +0,0 @@
from modeltranslation.translator import TranslationOptions, register
from .models import User
@register(User)
class UserModelTranslation(TranslationOptions):
"""Translation for mode User"""

View File

View File

@ -1,11 +1,12 @@
"""Account app urlconf."""
from django.urls import path
from account import views
from account.views import web as views
app_name = 'account'
urlpatterns = [
path('user/', views.UserView.as_view(), name='user_get_update'),
path('device/', views.FCMDeviceViewSet.as_view(), name='fcm_device_create'),
# path('reset-password/', views.ResetPasswordView.as_view(), name='reset-password'),
]

View File

View File

@ -4,7 +4,7 @@ from rest_framework import permissions
from rest_framework.response import Response
from account import models
from account import serializers
from account.serializers import web as serializers
class UserView(generics.RetrieveUpdateAPIView):

View File

@ -0,0 +1 @@
default_app_config = 'authorization.apps.AuthorizationConfig'

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,8 @@
"""Authorization app config."""
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class AuthorizationConfig(AppConfig):
name = 'authorization'
verbose_name = _('Authorization')

View File

@ -0,0 +1,39 @@
# Generated by Django 2.2.4 on 2019-08-09 05:47
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import oauth2_provider.generators
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Application',
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)),
('source', models.PositiveSmallIntegerField(choices=[(0, 'Mobile'), (1, 'Web')], default=0, verbose_name='Source')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='authorization_application', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
'swappable': 'OAUTH2_PROVIDER_APPLICATION_MODEL',
},
),
]

View File

@ -0,0 +1,39 @@
from django.db import models
from oauth2_provider.models import AbstractApplication
from oauth2_provider import models as oauth2_models
from django.utils.translation import gettext_lazy as _
# Create your models here.
class ApplicationQuerySet(models.QuerySet):
"""Application queryset"""
def get_by_natural_key(self, client_id):
return self.get(client_id=client_id)
def by_source(self, source: int):
"""Filter by source parameter"""
return self.filter(source=source)
class ApplicationManager(oauth2_models.ApplicationManager):
"""Application manager"""
class Application(AbstractApplication):
"""Custom oauth2 application model"""
MOBILE = 0
WEB = 1
SOURCES = (
(MOBILE, _('Mobile')),
(WEB, _('Web')),
)
source = models.PositiveSmallIntegerField(choices=SOURCES, default=MOBILE,
verbose_name=_('Source'))
objects = ApplicationManager.from_queryset(ApplicationQuerySet)()
class Meta(AbstractApplication.Meta):
swappable = "OAUTH2_PROVIDER_APPLICATION_MODEL"
def natural_key(self):
return (self.client_id,)

View File

@ -0,0 +1,10 @@
"""Common serializer for application authorization"""
from rest_framework import serializers
from authorization.models import Application
class SocialSignUpSerialzier(serializers.Serializer):
"""Serializer for signing up"""
source = serializers.ChoiceField(choices=Application.SOURCES)
token = serializers.CharField(max_length=255)

View File

@ -0,0 +1,81 @@
"""Serializers for application authorization"""
from rest_framework import serializers
from rest_framework.authentication import authenticate
from rest_framework import validators as rest_validators
from account import models as account_models
from django.contrib.auth import password_validation as password_validators
from django.utils.translation import gettext_lazy as _
class AuthTokenClassicSerializer(serializers.Serializer):
username = serializers.CharField(
label=_('Username'),
required=False
)
password = serializers.CharField(
label=_("Password"),
style={'input_type': 'password'},
trim_whitespace=False
)
email = serializers.EmailField(
label=_("Email"),
required=False
)
def validate(self, attrs):
username = attrs.get('username')
password = attrs.get('password')
email = attrs.get('email')
if username and password:
user = authenticate(request=self.context.get('request'),
username=username, password=password)
elif email and password:
user = authenticate(request=self.context.get('request'),
email=email, password=password)
else:
msg = _('Must include "phone" and "password".')
raise serializers.ValidationError(msg, code='authorization')
if user:
# From Django 1.10 onwards the `authenticate` call simply
# returns `None` for is_active=False users.
# (Assuming the default `ModelBackend` authentication backend.)
if not user.is_active:
msg = _('User account is disabled.')
raise serializers.ValidationError(msg, code='authorization')
attrs['user'] = user
return attrs
class SignUpSerializer(serializers.ModelSerializer):
"""Serializer for signing up user"""
email = serializers.CharField(
validators=(rest_validators.UniqueValidator(queryset=account_models.User.objects.all()), ),
write_only=True
)
username = serializers.CharField(
validators=(rest_validators.UniqueValidator(queryset=account_models.User.objects.all()), ),
write_only=True
)
password = serializers.CharField(write_only=True)
newsletter = serializers.BooleanField()
class Meta:
"""Meta-class"""
model = account_models.User
fields = ('email', 'username',
'newsletter', 'password')
def validate_password(self, data):
"""Custom password validation"""
try:
password_validators.validate_password(password=data)
except serializers.ValidationError as e:
raise serializers.ValidationError(e)
else:
return data
def create(self, validated_data):
"""Override create method"""
obj = account_models.User.objects.make(**validated_data)
return obj

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

View File

@ -0,0 +1,46 @@
"""Common url routing for application authorization"""
from django.conf import settings
from django.conf.urls import url, include
from django.urls import path
from oauth2_provider.views import AuthorizationView
from rest_framework_social_oauth2.views import (ConvertTokenView, TokenView,
RevokeTokenView, invalidate_sessions)
from social_core.utils import setting_name
from social_django import views as social_django_views
from authorization.views import web as views
extra = getattr(settings, setting_name('TRAILING_SLASH'), True) and '/' or ''
app_name = 'social'
urlpatterns_social_django = [
# authentication / association
url(r'^login/(?P<backend>[^/]+){0}$'.format(extra), social_django_views.auth,
name='begin'),
url(r'^complete/(?P<backend>[^/]+){0}$'.format(extra), social_django_views.complete,
name='complete'),
# disconnection
url(r'^disconnect/(?P<backend>[^/]+){0}$'.format(extra), social_django_views.disconnect,
name='disconnect'),
url(r'^disconnect/(?P<backend>[^/]+)/(?P<association_id>\d+){0}$'
.format(extra), social_django_views.disconnect, name='disconnect_individual'),
]
urlpatterns_rest_framework_social_oauth2 = [
url(r'^authorize/?$', AuthorizationView.as_view(), name="authorize"),
url(r'^token/?$', TokenView.as_view(), name="token"),
url('', include('social_django.urls', namespace="social")),
url(r'^convert-token/?$', ConvertTokenView.as_view(), name="convert_token"),
url(r'^revoke-token/?$', RevokeTokenView.as_view(), name="revoke_token"),
url(r'^invalidate-sessions/?$', invalidate_sessions, name="invalidate_sessions")
]
urlpatterns_api = [
path('social/signup/', views.SocialSignUpView.as_view(), name='signup'),
]
urlpatterns = urlpatterns_api + \
urlpatterns_social_django + \
urlpatterns_rest_framework_social_oauth2

View File

@ -0,0 +1,6 @@
"""Url routing for application authorization"""
app_name = 'auth'
urlpatterns = [
]

View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

View File

@ -0,0 +1,6 @@
"""Common views for application authorization"""
from django.shortcuts import render
from rest_framework import views as rest_views
# Create your views here.

View File

@ -0,0 +1,82 @@
"""Views for application Account"""
import json
from braces.views import CsrfExemptMixin
from oauth2_provider.settings import oauth2_settings
from oauth2_provider.views.mixins import OAuthLibMixin
from rest_framework import permissions
from authorization.models import Application
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from rest_framework.response import Response
from rest_framework.generics import GenericAPIView
from rest_framework_social_oauth2.oauth2_backends import KeepRequestCore
from rest_framework_social_oauth2.oauth2_endpoints import SocialTokenServer
from authorization.serializers import common as serializers
from utils import exceptions as utils_exceptions
# Create your views here.
class SocialSignUpView(CsrfExemptMixin, OAuthLibMixin, GenericAPIView):
"""
Implements an endpoint to convert a provider token to an access token
The endpoint is used in the following flows:
* Authorization code
* Client credentials
"""
server_class = SocialTokenServer
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
oauthlib_backend_class = KeepRequestCore
permission_classes = (permissions.AllowAny,)
serializer_class = serializers.SocialSignUpSerialzier
def get_client_id(self, source) -> str:
"""Get application client id"""
qs = Application.objects.by_source(source=source)
if qs.exists():
return qs.first().client_id
else:
raise utils_exceptions.SerivceError(data=_('Application is not found'))
def get_client_secret(self, source) -> str:
"""Get application client id"""
if source == Application.MOBILE:
qs = Application.objects.by_source(source=source)
if qs.exists:
return qs.first().client_secret
else:
raise utils_exceptions.SerivceError(
data=_('Not found an application with this source'))
def post(self, request, *args, **kwargs):
"""Override POST method"""
# Serialize POST-data
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Set attributes
source = serializer.validated_data.get('source')
token = serializer.validated_data.get('token')
# Set OAuth2 request parameters
request_data = {
'grant_type': settings.OAUTH2_SOCIAL_AUTH_GRANT_TYPE,
'backend': settings.OAUTH2_SOCIAL_AUTH_BACKEND_NAME,
'token': token,
'client_id': self.get_client_id(source)
}
# Fill client secret parameter by platform
if source == Application.MOBILE:
request_data['client_secret'] = self.get_client_secret(source)
# Use the rest framework `.data` to fake the post body of the django request.
request._request.POST = request._request.POST.copy()
for key, value in request_data.items():
request._request.POST[key] = value
url, headers, body, status = self.create_token_response(request._request)
response = Response(data=json.loads(body), status=status)
for k, v in headers.items():
response[k] = v
return response

15
apps/utils/exceptions.py Normal file
View File

@ -0,0 +1,15 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, status
class SerivceError(exceptions.APIException):
"""Service error."""
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
default_detail = _('Service is temporarily unavailable')
def __init__(self, data=None):
if data:
self.default_detail = {
**data
}
super().__init__()

View File

@ -24,6 +24,14 @@ class ProjectBaseMixin(models.Model):
abstract = True
class OAuthProjectMixin:
"""OAuth2 mixin for project GM"""
def get_source(self):
"""Method to get of platform"""
return NotImplemented
basemixin_fields = ['created', 'modified']

36
apps/utils/oauth2.py Normal file
View File

@ -0,0 +1,36 @@
from rest_framework_social_oauth2.backends import DjangoOAuth2
from oauth2_provider.models import AccessToken
from django.contrib.auth.models import User
class GMOAuth2(DjangoOAuth2):
def get_user_details(self, response):
if response.get(self.ID_KEY, None):
user = User.objects.get(pk=response[self.ID_KEY])
return {'username': user.username,
'email': user.email,
'fullname': user.get_full_name(),
'first_name': user.first_name,
'last_name': user.last_name
}
return {}
def user_data(self, access_token, *args, **kwargs):
try:
user_id = AccessToken.objects.get(token=access_token).user.pk
return {self.ID_KEY: user_id}
except AccessToken.DoesNotExist:
return None
def do_auth(self, access_token, *args, **kwargs):
"""Finish the auth process once the access_token was retrieved"""
data = self.user_data(access_token, *args, **kwargs)
response = kwargs.get('response') or {}
response.update(data or {})
kwargs.update({'response': response, 'backend': self})
if response.get(self.ID_KEY, None):
user = User.objects.get(pk=response[self.ID_KEY])
return user
else:
return None

View File

@ -52,7 +52,7 @@ CONTRIB_APPS = [
PROJECT_APPS = [
'account.apps.AccountConfig',
'location.apps.LocationConfig',
'authorization.apps.AuthorizationConfig',
]
@ -225,7 +225,7 @@ AUTHENTICATION_BACKENDS = (
'social_core.backends.facebook.FacebookOAuth2',
# django-rest-framework-social-oauth2
'rest_framework_social_oauth2.backends.DjangoOAuth2',
'utils.oauth2.GMOAuth2',
# Django
'django.contrib.auth.backends.ModelBackend',
@ -236,6 +236,12 @@ OAUTH2_PROVIDER = {
'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'groups': 'Access to your groups'}
}
# Override default OAuth2 namespace
DRFSO2_URL_NAMESPACE = 'oauth2'
OAUTH2_SOCIAL_AUTH_BACKEND_NAME = 'facebook'
OAUTH2_SOCIAL_AUTH_GRANT_TYPE = 'convert_token'
OAUTH2_PROVIDER_APPLICATION_MODEL = 'authorization.Application'
# SMS Settings
SMS_EXPIRATION = 5
SMS_SEND_DELAY = 30

View File

@ -18,10 +18,13 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include, re_path
from drf_yasg import openapi
from django.conf.urls import url
from drf_yasg.views import get_schema_view
from rest_framework import permissions
# URL platform patterns
from project.urls import web as web_urlpatterns
schema_view = get_schema_view(
openapi.Info(
title="G&M API",
@ -48,26 +51,18 @@ urlpatterns_doc = [
]
urlpatterns_oauth2 = [
url(r'^auth/', include('rest_framework_social_oauth2.urls')),
urlpatterns_social = [
path('api/oauth2/', include('authorization.urls.common', namespace='oauth2')),
]
urlpatterns_api = [
path('account/', include('account.urls'), name='account'),
]
urlpatterns = [
path('admin/', admin.site.urls),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
path('api/', include(urlpatterns_api)),
path('api/web/', include(web_urlpatterns)),
]
urlpatterns = urlpatterns + \
static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + \
urlpatterns_oauth2
urlpatterns_social + \
static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG:

22
project/urls/web.py Normal file
View File

@ -0,0 +1,22 @@
"""project 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.urls import path, include
app_name = 'web'
urlpatterns = [
path('account/', include('account.urls.web')),
]