This commit is contained in:
mahdavi 2024-07-03 18:18:17 +03:30
parent 250e2a3edd
commit 7ba4ed647f
44 changed files with 105 additions and 4320 deletions

View file

@ -1,36 +0,0 @@
"""
Django admin configuration for the gooyal-restrict-scopes package.
"""
from django.contrib import admin
from django.contrib.admin.sites import NotRegistered
from oauth2_provider.admin import ApplicationAdmin
from .models import Application, Resource, Scope
from .forms import ApplicationForm
# 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(Application)
except NotRegistered:
pass
@admin.register(Application)
class ApplicationAdmin(ApplicationAdmin):
form = ApplicationForm
@admin.register(Resource)
class ResourceAdmin(admin.ModelAdmin):
list_display = ("name", "user", "expires")
@admin.register(Scope)
class ScopeAdmin(admin.ModelAdmin):
list_display = ('name', "resource", 'description', 'is_default')
# admin.site.register(RestrictedApplication, RestrictedApplicationAdmin)

View file

@ -1,5 +0,0 @@
from django.apps import AppConfig
class GooyalOauthConfig(AppConfig):
name = 'apps.accounts_oauth2'

View file

@ -1,51 +0,0 @@
"""
Django forms for use with the gooyal-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 ApplicationForm(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 = ()

View file

@ -1,123 +0,0 @@
# Generated by Django 4.1 on 2022-08-17 19:05
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import oauth2_provider.generators
import oauth2_provider.models
import uuid
class Migration(migrations.Migration):
initial = True
run_before = [
('oauth2_provider', '0001_initial'),
]
dependencies = [
]
operations = [
migrations.CreateModel(
name='AccessToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('detail', models.JSONField(blank=True, null=True)),
],
options={
'abstract': False,
},
),
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'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)),
('client_secret', oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new 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)),
('algorithm', models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5)),
('allowed_scope', models.TextField(blank=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Grant',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('code', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('redirect_uri', models.TextField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('code_challenge', models.CharField(blank=True, default='', max_length=128)),
('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)),
('nonce', models.CharField(blank=True, default='', max_length=255)),
('claims', models.TextField(blank=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='IDToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='RefreshToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.CharField(max_length=255)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('revoked', models.DateTimeField(null=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Resource',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255)),
('expires', models.DateTimeField()),
],
),
migrations.CreateModel(
name='Scope',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='The name of the scope.', max_length=255, unique=True)),
('description', models.TextField(help_text='A brief description of the scope. This text is displayed to users when authorising access for the scope.')),
('is_default', models.BooleanField(default=False, help_text='Indicates if this scope should be included in the default scopes.')),
('application', models.ForeignKey(blank=True, help_text='The application to which the scope belongs.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scopes', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('resource', models.ForeignKey(blank=True, help_text='The resource of scope.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='scopes', to='accounts_oauth2.resource')),
],
),
]

View file

@ -1,92 +0,0 @@
# Generated by Django 4.1 on 2022-08-17 19:05
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts_oauth2', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='resource',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='resources', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='refreshtoken',
name='access_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL),
),
migrations.AddField(
model_name='refreshtoken',
name='application',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refresh_tokens', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
migrations.AddField(
model_name='refreshtoken',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='idtoken',
name='application',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
migrations.AddField(
model_name='idtoken',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='grant',
name='application',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='grants', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
migrations.AddField(
model_name='grant',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='application',
name='resource',
field=models.OneToOneField(blank=True, help_text='The resource of application.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='application', to='accounts_oauth2.resource'),
),
migrations.AddField(
model_name='application',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='accesstoken',
name='application',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
migrations.AddField(
model_name='accesstoken',
name='id_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL),
),
migrations.AddField(
model_name='accesstoken',
name='source_refresh_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),
),
migrations.AddField(
model_name='accesstoken',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AlterUniqueTogether(
name='refreshtoken',
unique_together={('token', 'revoked')},
),
]

View file

@ -1,38 +0,0 @@
# Generated by Django 5.0.6 on 2024-06-26 12:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts_oauth2', '0002_initial'),
]
operations = [
migrations.AddField(
model_name='application',
name='allowed_origins',
field=models.TextField(blank=True, default='', help_text='Allowed origins list to enable CORS, space separated'),
),
migrations.AddField(
model_name='application',
name='hash_client_secret',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='application',
name='post_logout_redirect_uris',
field=models.TextField(blank=True, default='', help_text='Allowed Post Logout URIs list, space separated'),
),
migrations.AlterField(
model_name='accesstoken',
name='token',
field=models.CharField(db_index=True, max_length=255, unique=True),
),
migrations.AlterField(
model_name='scope',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View file

@ -1,168 +0,0 @@
import requests
from django.conf import settings
from django.db import models
from django.db.models import JSONField
from oauth2_provider.models import AbstractApplication, AbstractAccessToken, AbstractGrant, AbstractRefreshToken, \
AbstractIDToken
from oauth2_provider.scopes import get_scopes_backend
from oauth2_provider.settings import oauth2_settings
import uuid
class Resource(models.Model):
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
name = models.CharField(max_length=255)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True,
related_name="resources"
)
expires = models.DateTimeField()
def __str__(self):
return self.name
class Application(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.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
related_name="%(app_label)s_%(class)s",
on_delete=models.PROTECT
)
allowed_scope = models.TextField(blank=True)
resource = models.OneToOneField(
Resource,
models.PROTECT,
blank=True, null=True,
help_text='The resource of application.',
related_name='application'
)
@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)
class Scope(models.Model):
"""
Django model for an OAuth scope.
"""
#: The application that created the scope
# NOTE: This is not used to limit access to the scope in any way - we want the
# scope to be available to other applications in order to request access
# to the resource it protects!
application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL,
models.CASCADE,
# This field is nullable because it is only set for scopes created by
# external resource servers, which have a corresponding OAuth application
# record on the authorisation server
blank=True, null=True,
help_text='The application to which the scope belongs.',
related_name='scopes'
)
resource = models.ForeignKey(
Resource,
models.PROTECT,
blank=True, null=True,
help_text='The resource of scope.',
related_name='scopes'
)
#: The name of the scope
name = models.CharField(
max_length=255,
unique=True,
help_text='The name of the scope.'
)
#: A brief description of the scope
description = models.TextField(
help_text='A brief description of the scope. This text is displayed '
'to users when authorising access for the scope.'
)
#: Indicates if the scope should be included in the default scopes
is_default = models.BooleanField(
default=False,
help_text='Indicates if this scope should be included in the default scopes.'
)
@property
def final_name(self):
args = []
if self.resource:
args.append(self.resource.name)
args.append(self.name)
return '.'.join(args)
@property
def final_description(self):
resource_name = self.resource and self.resource.name
if resource_name:
return f"{resource_name} -> {self.description}"
return self.description
@classmethod
def register(cls, name, description, is_default=False):
"""
Registers a scope with the given values. It always creates an instance in
the local database, but if this resource server has an external authorisation
server, it will also register the scope there.
Returns ``True`` on success. Should raise on failure.
"""
endpoint = settings.RESOURCE_SERVER_REGISTER_SCOPE_URL
if endpoint:
#  If the endpoint is set, make the callout to the authz server
token = "Bearer {}".format(oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN)
#  Let any failures bubble up
# The idea is to call this method during deployment as a post-migrate
#  hook, so we want failures to halt the deployment
response = requests.post(
endpoint,
json={
'name': name,
'description': description,
'is_default': is_default
},
headers={"Authorization": token}
)
#  Raise the exception for anything other than 20x responses
response.raise_for_status()
#  Always create/update the scope record locally
_ = Scope.objects.update_or_create(
name=name,
defaults={'description': description, 'is_default': is_default}
)
return True
class Grant(AbstractGrant):
application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, related_name='grants'
)
class RefreshToken(AbstractRefreshToken):
application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, related_name='refresh_tokens')
class AccessToken(AbstractAccessToken):
detail = JSONField(null=True, blank=True)
application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name='access_tokens'
)
class IDToken(AbstractIDToken):
pass

View file

@ -1,33 +0,0 @@
"""
Django OAuth Toolkit scopes backend for the gooyal-dynamic-scopes package.
"""
from django.conf import settings
from django.utils import module_loading
from oauth2_provider.scopes import BaseScopes
from .models import Scope
class Scopes(BaseScopes):
"""
Scopes backend that provides scopes from a Django model.
"""
def get_queryset(self, application=None):
queryset = Scope.objects.all()
if application:
queryset = queryset.filter(name__in=application.allowed_scopes).order_by('resource__uuid')
return queryset
def get_all_scopes(self):
return {scope.final_name: scope.final_description for scope in self.get_queryset().all()}
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
scopes = [scope.final_name for scope in self.get_queryset(application).all()]
return scopes
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
return [scope.final_name for scope in self.get_queryset(application).filter(is_default=True).all()]

View file

@ -1,20 +0,0 @@
from oauth2_provider.settings import OAuth2ProviderSettings, USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY
GOOYAL_DEFAULTS = {
# Resource Server with Token Introspection additions
"RESOURCE_SERVER_CLIENT_ID": None,
"RESOURCE_SERVER_CLIENT_SECRET": None,
# set default
"INTROSPECTION_USER_CREATE_METHOD": None,
}
GOOYAL_IMPORT_STRINGS = ("INTROSPECTION_USER_CREATE_METHOD",)
GOOYAL_MANDATORY = ("INTROSPECTION_USER_CREATE_METHOD",)
DEFAULTS.update(GOOYAL_DEFAULTS)
IMPORT_STRINGS = IMPORT_STRINGS + GOOYAL_IMPORT_STRINGS
MANDATORY = MANDATORY + GOOYAL_MANDATORY
oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY)

View file

@ -1,26 +0,0 @@
"""
Django signal handlers for the gooyal-dynamic-scopes package.
"""
from django.conf import settings
from oauth2_provider.settings import oauth2_settings
from .models import Scope
def register_scopes(app_config, verbosity=2, interactive=True, **kwargs):
"""
``post_migrate`` signal handler that ensures the scopes required for the
introspection and register-scope endpoints are registered when running as an
authorisation server.
The signal is connected in ``apps.py``.
"""
#  Register any scopes in the oauth2_provider settings
for name, description in oauth2_settings.SCOPES.items():
Scope.register(
name,
description,
name in oauth2_settings.DEFAULT_SCOPES
)

View file

@ -1,288 +0,0 @@
import base64
import binascii
import logging
from datetime import datetime, timedelta
from urllib.parse import unquote_plus
import requests
# import service_clients
from django.contrib.auth import get_user_model
from django.utils.timezone import make_aware
from oauth2_provider.models import get_access_token_model
from oauth2_provider.oauth2_validators import OAuth2Validator as BaseOAuth2Validator
from .settings import oauth2_settings
from django.conf import settings
log = logging.getLogger("oauth2_provider")
AccessTokenModel = get_access_token_model()
UserModel = get_user_model()
class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223
introspection_client = None
def get_introspection_client(self, introspection_client_id, introspection_client_secret):
if not OAuth2Validator.introspection_client:
OAuth2Validator.introspection_client = service_clients.Client(client_id=introspection_client_id,
client_secret=introspection_client_secret,
grant_type=service_clients.AccountsClient.GRANT_CLIENT_CREDENTIALS,
scopes=['introspection'])
return OAuth2Validator.introspection_client
def validate_user(self, username, password, client, request, *args, **kwargs):
auth_fields = getattr(request, 'auth_fields', 'username:password').split(':')
if len(auth_fields) != 2:
return False
user_field, pass_field = auth_fields
if user_field not in ['phone_number', 'username', 'email']:
return False
if pass_field not in ['password', 'otp', 'ott']:
return False
if not username or not password:
return False
user = UserModel.objects.filter(**{user_field: username}).first()
if not user:
return False
if not user.check_auth(pass_field, password):
return False
if user.is_active:
request.user = user
return True
return False
def _get_token_from_gooyal_authentication_server(
self, token, introspection_url, introspection_token, introspection_credentials, introspection_client_id,
introspection_client_secret
):
"""Use external introspection endpoint to "crack open" the token.
:param introspection_url: introspection endpoint URL
:param introspection_token: Bearer token
:param introspection_credentials: Basic Auth credentials (id,secret)
:return: :class:`models.AccessToken`
Some RFC 7662 implementations (including this one) use a Bearer token while others use Basic
Auth. Depending on the external AS's implementation, provide either the introspection_token
or the introspection_credentials.
If the resulting access_token identifies a username (e.g. Authorization Code grant), add
that user to the UserModel. Also cache the access_token up until its expiry time or a
configured maximum time.
"""
headers = None
response = None
if introspection_token:
headers = {"Authorization": "Bearer {}".format(introspection_token)}
try:
response = requests.post(
introspection_url,
data={"token": token}, headers=headers
)
except requests.exceptions.RequestException:
log.exception("Introspection: Failed POST to %r in token lookup", introspection_url)
return None
elif introspection_credentials:
client_id = introspection_credentials[0].encode("utf-8")
client_secret = introspection_credentials[1].encode("utf-8")
basic_auth = base64.b64encode(client_id + b":" + client_secret)
headers = {"Authorization": "Basic {}".format(basic_auth.decode("utf-8"))}
try:
response = requests.post(
introspection_url,
data={"token": token}, headers=headers
)
except requests.exceptions.RequestException:
log.exception("Introspection: Failed POST to %r in token lookup", introspection_url)
return None
elif introspection_client_id and introspection_client_secret:
data = {"token": token}
introspection_client = self.get_introspection_client(introspection_client_id, introspection_client_secret)
response = introspection_client.request(url=introspection_url, method='post', data=data,
required_scopes=['introspection'], login_required=True)
try:
content: dict = response.json()
except ValueError:
log.exception("Introspection: Failed to parse response as json")
return None
user = None
if "active" in content and content["active"] is True:
if "username" in content:
user, content = oauth2_settings.INTROSPECTION_USER_CREATE_METHOD(token, content)
max_caching_time = datetime.now() + timedelta(
seconds=oauth2_settings.RESOURCE_SERVER_TOKEN_CACHING_SECONDS
)
if "exp" in content:
expires = datetime.utcfromtimestamp(content["exp"])
if expires > max_caching_time:
expires = max_caching_time
else:
expires = max_caching_time
scope = content.get("scope", "")
expires = make_aware(expires)
access_token, _created = AccessTokenModel.objects.update_or_create(
token=token,
defaults={
"user": user,
"application": None,
"scope": scope,
"expires": expires,
"detail": content,
})
# try:
# access_token = AccessTokenModel.objects.select_related("application", "user").get(token=token)
# except AccessTokenModel.DoesNotExist:
# access_token = AccessTokenModel.objects.create(
# user=user,
# token=token,
# application=None,
# scope=scope,
# expires=expires,
# detail=content
# )
# else:
# access_token.expires = expires
# access_token.scope = scope
# access_token.detail = content
# access_token.save()
return access_token
# def validate_bearer_token(self, token, scopes, request):
# """
# When users try to access resources, check that provided token is valid
# """
# if not token:
# return False
#
# introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL
# introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN
# introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS
# introspection_client_id = oauth2_settings.RESOURCE_SERVER_CLIENT_ID
# introspection_client_secret = oauth2_settings.RESOURCE_SERVER_CLIENT_SECRET
#
# try:
# access_token = AccessTokenModel.objects.select_related("application", "user").get(token=token)
# except AccessTokenModel.DoesNotExist:
# access_token = None
#
# # if there is no token or it's invalid then introspect the token if there's an external OAuth server
# if not access_token or not access_token.is_valid(scopes):
# if introspection_url and (introspection_token or introspection_credentials or (introspection_client_id and
# introspection_client_secret)):
# access_token = self._get_token_from_gooyal_authentication_server(
# token,
# introspection_url,
# introspection_token,
# introspection_credentials,
# introspection_client_id,
# introspection_client_secret
# )
#
# if access_token and access_token.is_valid(scopes):
# request.client = access_token.application
# request.user = access_token.user
# request.scopes = scopes
#
# # this is needed by django rest framework
# request.access_token = access_token
# return True
# else:
# self._set_oauth2_error_on_request(request, access_token, scopes)
# return False
def _authenticate_basic_auth(self, request):
"""
Authenticates with HTTP Basic Auth.
Note: as stated in rfc:`2.3.1`, client_id and client_secret must be encoded with
"application/x-www-form-urlencoded" encoding algorithm.
"""
auth_string = self._extract_basic_auth(request)
if not auth_string:
return False
try:
encoding = request.encoding or settings.DEFAULT_CHARSET or "utf-8"
except AttributeError:
encoding = "utf-8"
try:
b64_decoded = base64.b64decode(auth_string)
except (TypeError, binascii.Error):
log.debug("Failed basic auth: %r can't be decoded as base64", auth_string)
return False
try:
auth_string_decoded = b64_decoded.decode(encoding)
except UnicodeDecodeError:
log.debug("Failed basic auth: %r can't be decoded as unicode by %r", auth_string, encoding)
return False
try:
client_id, client_secret = map(unquote_plus, auth_string_decoded.split(":", 1))
except ValueError:
log.debug("Failed basic auth, Invalid base64 encoding.")
return False
if self._load_application(client_id, request) is None:
log.debug("Failed basic auth: Application %s does not exist" % client_id)
return False
elif request.client.client_id != client_id:
log.debug("Failed basic auth: wrong client id %s" % client_id)
return False
# TODO: check why not work
elif not client_secret == request.client.client_secret:
log.debug("Failed basic auth: wrong client secret %s" % client_secret)
return False
else:
return True
def _authenticate_request_body(self, request):
"""
Try to authenticate the client using client_id and client_secret
parameters included in body.
Remember that this method is NOT RECOMMENDED and SHOULD be limited to
clients unable to directly utilize the HTTP Basic authentication scheme.
See rfc:`2.3.1` for more details.
"""
# TODO: check if oauthlib has already unquoted client_id and client_secret
try:
client_id = request.client_id
client_secret = request.client_secret
except AttributeError:
return False
if self._load_application(client_id, request) is None:
log.debug("Failed body auth: Application %s does not exists" % client_id)
return False
# TODO: check why not work
elif not client_secret == request.client.client_secret:
log.debug("Failed body auth: wrong client secret %s" % client_secret)
return False
else:
return True

13
apps/gooyal_oauth2/admin.py Executable file
View file

@ -0,0 +1,13 @@
"""
Django admin configuration for the gooyal-restrict-scopes package.
"""
from django.contrib import admin
from django.contrib.admin.sites import NotRegistered
from oauth2_provider.admin import ApplicationAdmin
# The restricted application is registered by Django OAuth Toolkit, but we want
# to provide our own admin that uses our form

6
apps/gooyal_oauth2/apps.py Executable file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class GooyalOauthConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.gooyal_oauth2'

View file

@ -0,0 +1,9 @@
"""
Django forms for use with the gooyal-restrict-scopes package.
"""
from django import forms
from oauth2_provider.scopes import get_scopes_backend

View file

@ -0,0 +1 @@
# models

View file

@ -0,0 +1,30 @@
import base64
import binascii
import logging
from datetime import datetime, timedelta
from urllib.parse import unquote_plus
import requests
# import service_clients
from django.contrib.auth import get_user_model
from django.utils.timezone import make_aware
from oauth2_provider.models import get_access_token_model
from oauth2_provider.oauth2_validators import OAuth2Validator as BaseOAuth2Validator
log = logging.getLogger("oauth2_provider")
AccessTokenModel = get_access_token_model()
UserModel = get_user_model()
class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223
def get_or_create_user_from_content(self, content):
"""
An optional layer to define where to store the profile in `UserModel` or a separate model.
For example `UserOAuth`, where `user = models.OneToOneField(UserModel)` .
The function is called after checking that username is in the content.
Returns an UserModel instance;
"""
user, _ = UserModel.objects.get_or_create(pk=content["username"])
return user

View file

@ -2,31 +2,4 @@ from django.contrib import admin
from .models import User
class UserAdmin(admin.ModelAdmin):
fields = [
'uuid',
'name',
'avatar',
'is_active',
'first_name',
'last_name',
'username',
'email',
'phone_number',
'password',
'otp',
'is_staff',
'is_superuser',
'groups',
'user_permissions',
'last_login',
'date_joined',
'last_update',
'otp_expire',
'otp_try',
'balance',
]
readonly_fields = ['last_update', 'uuid']
admin.site.register(User, UserAdmin)
admin.site.register(User)

View file

@ -2,4 +2,5 @@ from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.users'

View file

@ -1,137 +0,0 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
UserModel = get_user_model()
class OTPBackend(ModelBackend):
"""
Authenticates against settings.AUTH_USER_MODEL.
"""
def authenticate(self, request, phone_number=None, otp=None, **kwargs):
if phone_number is None or otp is None:
return
try:
user = UserModel.objects.get(phone_number=phone_number)
# user = UserModel._default_manager.get_by_natural_key(phone_number)
except UserModel.DoesNotExist:
return
else:
if user.check_otp(otp) and self.user_can_authenticate(user):
return user
def user_can_authenticate(self, user):
"""
Reject users with is_active=False. Custom user models that don't have
that attribute are allowed.
"""
is_active = getattr(user, 'is_active', None)
return is_active or is_active is None
# def _get_user_permissions(self, user_obj):
# return user_obj.user_permissions.all()
#
# def _get_group_permissions(self, user_obj):
# user_groups_field = get_user_model()._meta.get_field('groups')
# user_groups_query = 'group__%s' % user_groups_field.related_query_name()
# return Permission.objects.filter(**{user_groups_query: user_obj})
#
# def _get_permissions(self, user_obj, obj, from_name):
# """
# Return the permissions of `user_obj` from `from_name`. `from_name` can
# be either "group" or "user" to return permissions from
# `_get_group_permissions` or `_get_user_permissions` respectively.
# """
# if not user_obj.is_active or user_obj.is_anonymous or obj is not None:
# return set()
#
# perm_cache_name = '_%s_perm_cache' % from_name
# if not hasattr(user_obj, perm_cache_name):
# if user_obj.is_superuser:
# perms = Permission.objects.all()
# else:
# perms = getattr(self, '_get_%s_permissions' % from_name)(user_obj)
# perms = perms.values_list('content_type__app_label', 'codename').order_by()
# setattr(user_obj, perm_cache_name, {"%s.%s" % (ct, name) for ct, name in perms})
# return getattr(user_obj, perm_cache_name)
#
# def get_user_permissions(self, user_obj, obj=None):
# """
# Return a set of permission strings the user `user_obj` has from their
# `user_permissions`.
# """
# return self._get_permissions(user_obj, obj, 'user')
#
# def get_group_permissions(self, user_obj, obj=None):
# """
# Return a set of permission strings the user `user_obj` has from the
# groups they belong.
# """
# return self._get_permissions(user_obj, obj, 'group')
#
# def get_all_permissions(self, user_obj, obj=None):
# if not user_obj.is_active or user_obj.is_anonymous or obj is not None:
# return set()
# if not hasattr(user_obj, '_perm_cache'):
# user_obj._perm_cache = super().get_all_permissions(user_obj)
# return user_obj._perm_cache
#
# def has_perm(self, user_obj, perm, obj=None):
# return user_obj.is_active and super().has_perm(user_obj, perm, obj=obj)
#
# def has_module_perms(self, user_obj, app_label):
# """
# Return True if user_obj has any permissions in the given app_label.
# """
# return user_obj.is_active and any(
# perm[:perm.index('.')] == app_label
# for perm in self.get_all_permissions(user_obj)
# )
#
# def with_perm(self, perm, is_active=True, include_superusers=True, obj=None):
# """
# Return users that have permission "perm". By default, filter out
# inactive users and include superusers.
# """
# if isinstance(perm, str):
# try:
# app_label, codename = perm.split('.')
# except ValueError:
# raise ValueError(
# 'Permission name should be in the form '
# 'app_label.permission_codename.'
# )
# elif not isinstance(perm, Permission):
# raise TypeError(
# 'The `perm` argument must be a string or a permission instance.'
# )
#
# UserModel = get_user_model()
# if obj is not None:
# return UserModel._default_manager.none()
#
# permission_q = Q(group__user=OuterRef('pk')) | Q(user=OuterRef('pk'))
# if isinstance(perm, Permission):
# permission_q &= Q(pk=perm.pk)
# else:
# permission_q &= Q(codename=codename, content_type__app_label=app_label)
#
# user_q = Exists(Permission.objects.filter(permission_q))
# if include_superusers:
# user_q |= Q(is_superuser=True)
# if is_active is not None:
# user_q &= Q(is_active=is_active)
#
# return UserModel._default_manager.filter(user_q)
#
# def get_user(self, user_id):
# try:
# user = UserModel._default_manager.get(pk=user_id)
# except UserModel.DoesNotExist:
# return None
# return user if self.user_can_authenticate(user) else None
#
#

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
MAX_OTP_TRY = 3
DEVELOPMENT_PHONE_NUMBERS = ['+989999999999', '+989999999998']

View file

@ -6,85 +6,3 @@ from django.utils.translation import gettext_lazy as _
UserModel = get_user_model()
class OTPAuthenticationForm(forms.Form):
"""
Base class for authenticating users. Extend this to get a form that accepts
username/password logins.
"""
phone_number = UsernameField(widget=forms.TextInput(attrs={'autofocus': True}))
otp = forms.CharField(
label=_("otp"),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
error_messages = {
'invalid_login': _(
"Please enter a correct %(phone_number)s and otp. Note that both "
"fields may be case-sensitive."
),
'inactive': _("This account is inactive."),
}
def __init__(self, request=None, *args, **kwargs):
"""
The 'request' parameter is set for custom auth use by subclasses.
The form data comes in via the standard 'data' kwarg.
"""
self.request = request
self.user_cache = None
super().__init__(*args, **kwargs)
# Set the max length and label for the "username" field.
self.phone_number_field = UserModel._meta.get_field('phone_number')
phone_number_max_length = self.phone_number_field.max_length or 24
self.fields['phone_number'].max_length = phone_number_max_length
self.fields['phone_number'].widget.attrs['maxlength'] = phone_number_max_length
if self.fields['phone_number'].label is None:
self.fields['phone_number'].label = capfirst(self.phone_number_field.verbose_name)
def clean(self):
phone_number = self.cleaned_data.get('phone_number')
otp = self.cleaned_data.get('otp')
if phone_number is not None and otp:
self.user_cache = authenticate(self.request, phone_number=phone_number, otp=otp)
if self.user_cache is None:
raise self.get_invalid_login_error()
else:
self.confirm_login_allowed(self.user_cache)
return self.cleaned_data
def confirm_login_allowed(self, user):
"""
Controls whether the given User may log in. This is a policy setting,
independent of end-user authentication. This default behavior is to
allow login by active users, and reject login by inactive users.
If the given user cannot log in, this method should raise a
``forms.ValidationError``.
If the given user may log in, this method should return None.
"""
if not user.is_active:
raise forms.ValidationError(
self.error_messages['inactive'],
code='inactive',
)
def get_user(self):
return self.user_cache
def get_invalid_login_error(self):
return forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login',
params={'phone_number': self.phone_number_field.verbose_name},
)
class ProfileUpdateForm(forms.ModelForm):
class Meta:
model = UserModel
fields = ['name', 'first_name', 'last_name', 'email', 'iban', 'avatar']

View file

@ -1,10 +1,10 @@
# Generated by Django 4.1 on 2022-08-17 19:05
# Generated by Django 5.0.6 on 2024-07-03 14:33
import apps.users.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
@ -20,28 +20,18 @@ class Migration(migrations.Migration):
name='User',
fields=[
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(blank=True, max_length=30, verbose_name='name')),
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
('first_name', models.CharField(blank=True, max_length=150, 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')),
('province', models.IntegerField(blank=True, null=True, verbose_name='Province')),
('city', models.IntegerField(blank=True, null=True, verbose_name='City')),
('postal_code', models.CharField(blank=True, max_length=20, null=True, verbose_name='postal_code')),
('address', models.TextField(blank=True, null=True, verbose_name='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')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('password', models.CharField(max_length=128, verbose_name='password')),
('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')),
('balance', models.BigIntegerField(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')),
('last_update', models.DateTimeField(auto_now=True, max_length=30, null=True, verbose_name='last update')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),

View file

@ -1,18 +0,0 @@
# Generated by Django 4.1 on 2022-09-08 13:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='email_verified',
field=models.BooleanField(null=True),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 4.1 on 2022-09-15 07:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0002_user_email_verified'),
]
operations = [
migrations.AddField(
model_name='user',
name='ott',
field=models.CharField(blank=True, max_length=36, null=True, verbose_name='one time token'),
),
migrations.AddField(
model_name='user',
name='ott_expire',
field=models.DateTimeField(blank=True, null=True, verbose_name='otp expire'),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 4.1 on 2022-09-25 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_user_ott_user_ott_expire'),
]
operations = [
migrations.AlterField(
model_name='user',
name='balance',
field=models.BigIntegerField(default=0, verbose_name='balance'),
),
]

View file

@ -12,40 +12,22 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
import uuid
from .provinces_and_cities import state
# from .tasks import send_notification
from apps.users.constans import MAX_OTP_TRY, DEVELOPMENT_PHONE_NUMBERS
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 and not username:
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()
def _create_user(self, pk, **extra_fields):
user = self.model(pk=pk **extra_fields)
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):
def create_user(self, pk, **extra_fields):
extra_fields.setdefault('is_staff', False)
extra_fields.setdefault('is_superuser', False)
return self._create_user(phone_number=phone_number,
username=username,
email=email,
password=password,
**extra_fields)
return self._create_user(pk=pk, **extra_fields)
def create_superuser(self, username, email, password, **extra_fields):
if not username:
@ -64,36 +46,6 @@ class UserManager(BaseUserManager):
class User(AbstractUser):
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
name = models.CharField(_('name'), max_length=30, blank=True)
first_name = models.CharField(_('first name'), max_length=30, blank=True)
last_name = models.CharField(_('last name'), max_length=150, blank=True)
email = models.EmailField(_('email address'), blank=True)
email_verified = models.BooleanField(null=True)
# country = models.CharField(_('country'), max_length=50, blank=True, null=True)
# province = models.CharField(_('province'), max_length=50, blank=True, null=True)
# city = models.CharField(_('city'), max_length=50, blank=True, null=True)
# postal_code = models.CharField(_('postal_code'), max_length=20, blank=True, null=True)
# address = models.TextField(_('address'), null=True, blank=True)
province = models.IntegerField(verbose_name=_("Province"), blank=True, null=True)
city = models.IntegerField(verbose_name=_("City"), blank=True, null=True)
postal_code = models.CharField(_('postal_code'), max_length=20, blank=True, null=True)
address = models.TextField(_('address'), null=True, blank=True)
is_staff = models.BooleanField(
_('staff status'),
default=False,
help_text=_('Designates whether the user can log into this admin site.'),
)
is_active = models.BooleanField(
_('active'),
default=True,
help_text=_(
'Designates whether this user should be treated as active. '
'Unselect this instead of deleting accounts.'
),
)
password = models.CharField(_('password'), max_length=128)
username_validator = UnicodeUsernameValidator()
username = models.CharField(
@ -108,157 +60,17 @@ class User(AbstractUser):
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)
ott = models.CharField(_('one time token'), max_length=36, blank=True, null=True)
ott_expire = models.DateTimeField(_('otp expire'), blank=True, null=True)
last_checkout_request = models.DateTimeField(_('otp expire'), max_length=30, blank=True, null=True)
balance = models.BigIntegerField(_('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)
last_update = models.DateTimeField(_('last update'), max_length=30, blank=True, null=True, auto_now=True)
last_login = models.DateTimeField(_('last login'), blank=True, null=True)
date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
objects = UserManager()
@property
def city_name(self):
return state.get_city_by_id(self.city)
@property
def province_name(self):
return state.get_province_by_id(self.province)
def set_otp(self):
if settings.DEBUG or 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
def set_ott(self):
self.ott = ''.join(random.choice(string.ascii_letters+string.digits) for _ in range(32))
# self.ott = ''.join(random.choice(string.ascii_letters+string.digits+string.punctuation) for _ in range(32))
self.ott_expire = timezone.now() + timedelta(seconds=300)
def otp_is_valid(self):
return bool(self.otp and timezone.now() <= self.otp_expire)
def ott_is_valid(self):
return bool(self.ott and timezone.now() <= self.ott_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_ott(self, ott:str):
ott = ott.strip()
if ott and self.ott == ott and self.ott_is_valid():
self.ott = None
self.date_joined = timezone.now()
result = True
else:
self.ott = None
result = False
self.save()
return result
def check_auth(self, field, value):
result = False
if field == 'otp':
result = self.check_otp(value)
elif field == 'password':
result = self.check_password(value)
elif field == 'ott':
result = self.check_ott(value)
if result:
self.last_login = timezone.now()
self.save()
return result
def send_otp(self):
print(self.otp)
self.notify(body=str(self.otp), title='OTP')
def notify(self, body, title=None, notification_type='sms'):
# TODO: enable celery
send_notification(phone_number=self.phone_number,
title=title,
body=body,
user_uuid=self.uuid,
notification_type=1,
)
def __str__(self):
return self.name or self.get_full_name() or self.username or self.phone_number or self.email or _('no name')
def create_user_in_introspection(token, content):
# content = {
# "active": True,
# "scope": "read write email",
# "client_id": "J8NFmU4tJVgDxKaJFmXTWvaHO",
# "username": "aaronpk",
# "exp": 1437275311
# }
user_client = service_clients.Client(access_token=token,
scopes=content.get('scope', '').split(),
expires_in=100)
user_account = user_client.accounts.account()
if user_account.get('uuid'):
content.update(user_account)
# content = {'active': True, 'scope': 'ipg.payment:submit accounts.account:retrieve', 'exp': 1602619137,
# 'client_id': 'bd2hEGXytrqMjbFnplRyHJTeoW1vwKzCZJtH6ro0', 'username': '',
# 'uuid': '94255117-117c-4a9f-af50-35a6c47503ac', 'email': '', 'phone_number': '+989106853582',
# 'first_name': '', 'last_name': '', 'name': '', 'balance': 0,
# 'avatar': 'http://accounts.gooyal.com/media/avatars/1691899.jpg', 'iban': '', 'iban_verified': None}
user = User.objects.filter(uuid=content['uuid']).first()
if user:
pass
else:
user = User.objects.create_user(
**{User.USERNAME_FIELD: content["username"]},
uuid=content['uuid'],
email=content['email'],
phone_number=content['phone_number'],
first_name=content['first_name'],
last_name=content['last_name'],
name=content['name'],
avatar=content['avatar']
)
return user, content
return str(self.username or self.pk)

File diff suppressed because it is too large Load diff

View file

@ -5,117 +5,13 @@ from apps.users.models import User
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
phone_number_validator = RegexValidator(regex=r'^\+98\d{10,10}$', message=_(
"Phone number must be entered in the format: '+989999999999'."
),)
class PublicUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ('avatar', 'name', 'username', "first_name", "last_name", "uuid")
read_only_fields = ['avatar', 'name', 'username', 'first_name', 'last_name', "uuid"]
class AccountSerializer(serializers.ModelSerializer):
phone_number = serializers.CharField(validators=[phone_number_validator], required=False, read_only=True)
class Meta:
model = User
fields = (
"uuid",
'username',
'email',
'phone_number',
"first_name",
"last_name",
'name',
'avatar',
'iban',
'iban_verified',
# 'country',
'province',
'province_name',
'city',
'city_name',
'postal_code',
'address',
)
read_only_fields = ['uuid', 'email', 'phone_number', 'iban_verified', 'province_name', 'city_name',]
class RequestOTPSerializer(serializers.ModelSerializer):
phone_number = serializers.CharField(required=True, validators=[phone_number_validator])
ttl = serializers.SerializerMethodField()
class Meta:
model = User
fields = (
'phone_number',
'otp_expire',
'ttl'
)
read_only_fields = ['otp_expire', 'ttl']
def save(self, **kwargs):
# TODO: move it to query set
validated_data = self.validated_data
user = User.objects.filter(phone_number=validated_data['phone_number']).first()
if not user:
validated_data['username'] = validated_data['phone_number']
user = User.objects.create_user(**validated_data)
if not user.otp_is_valid():
user.set_otp()
user.save()
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 RequestOTTSerializer(serializers.ModelSerializer):
application = serializers.CharField(required=True, write_only=True)
class Meta:
model = User
fields = (
'application',
'ott',
'ott_expire',
)
read_only_fields = ['ott', 'ott_expire']
class UserInquirySerializer(serializers.ModelSerializer):
phone_number = serializers.CharField(required=True, validators=[phone_number_validator])
class Meta:
model = User
fields = ('avatar', 'name', 'username', "first_name", "last_name", "uuid", 'phone_number')
read_only_fields = ['avatar', 'name', 'username', 'first_name', 'last_name', "uuid"]
def save(self, **kwargs):
# TODO: move it to query set
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)
self.instance = user
return user
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(required=True)
old_password_field = serializers.CharField(default='password')
new_password = serializers.CharField(required=True)
read_only_fields = ['uuid', 'iban_verified']

View file

@ -1,28 +0,0 @@
# from celery import shared_task
# from django.conf import settings
# # from service_clients import Client, AccountsClient
#
# # SCOPES = ['notifications.notification:submit']
# # client = Client(client_id=settings.CLIENT_ID,
# # client_secret=settings.CLIENT_SECRET,
# # scopes=SCOPES,
# # grant_type=AccountsClient.GRANT_CLIENT_CREDENTIALS)
#
# @shared_task
# def send_notification(phone_number=None,
# title=None,
# body=None,
# user_uuid=None,
# email=None,
# notification_type=1):
#
# data = {
# 'phone_number': phone_number,
# "body": f'{title}:\n{body}'
# }
# try:
# print(client.request(required_scopes=SCOPES, url=f'{settings.BASE_NOTIFICATION_URL}/notifications/', data=data,
# method='post', timeout=5).text)
# except Exception as e:
# print(e)
#

View file

@ -1,19 +1,8 @@
from django.urls import path
from django.contrib.auth.views import LogoutView
from .views import UserListView, UserDetailView, AccountView, RequestOTPView, ChangePasswordView, \
OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView
from .views import AccountView
app_name = "users"
urlpatterns = [
path('login/', OTPLoginView.as_view(), name='login'),
path('logout/', LogoutView.as_view(), name='logout'),
path('profile/', ProfileDetailView.as_view(), name='profile_detail'),
path('profile/update/', ProfileUpdateView.as_view(), name='profile_update'),
path('api/account/', AccountView.as_view(), name='account_api'),
path('api/users/', UserListView.as_view(), name='user_list_api'),
path('api/users/<uuid>/', UserDetailView.as_view(), name='user_detail_api'),
path('api/request_otp/', RequestOTPView.as_view(), name='request_otp_api'),
path('api/request_ott/', RequestOTTView.as_view(), name='request_ott_api'),
path('api/change_password/', ChangePasswordView.as_view(), name='change_password_api'),
]

View file

@ -11,35 +11,13 @@ from rest_framework import generics, status, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.users.forms import OTPAuthenticationForm, ProfileUpdateForm
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.users.models import User
from apps.users.provinces_and_cities import State
from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \
ChangePasswordSerializer, UserInquirySerializer
from apps.users.serializers import AccountSerializer
UserModel = get_user_model()
class UserListView(generics.ListAPIView):
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
queryset = User.objects.all()
serializer_class = PublicUserSerializer
required_alternate_scopes = {
"GET": [["accounts.profile:list"]],
}
class UserDetailView(generics.RetrieveAPIView):
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
queryset = User.objects.all()
serializer_class = PublicUserSerializer
lookup_field = 'uuid'
required_alternate_scopes = {
"GET": [["accounts.profile:retrieve"]],
}
class AccountView(generics.RetrieveUpdateAPIView):
serializer_class = AccountSerializer
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
@ -57,72 +35,6 @@ class AccountView(generics.RetrieveUpdateAPIView):
serializer.save(last_update=timezone.now())
class RequestOTPView(generics.CreateAPIView):
permission_classes = []
serializer_class = RequestOTPSerializer
required_scopes = []
class RequestOTTView(generics.CreateAPIView):
permission_classes = [IsAuthenticatedOrTokenHasScope]
required_scopes = ['accounts.account:request_ott']
serializer_class = RequestOTTSerializer
def get_object(self):
return self.request.user
def create(self, request, *args, **kwargs):
instance: User = self.get_object()
serializer = self.get_serializer(instance, data=request.data)
serializer.is_valid(raise_exception=True)
instance.set_ott()
instance.save()
return Response(serializer.data)
class UserInquiryView(generics.CreateAPIView):
serializer_class = UserInquirySerializer
permission_classes = [IsAuthenticatedOrTokenHasScope]
required_scopes = ['accounts.profile:inquiry']
class ChangePasswordView(generics.UpdateAPIView):
permission_classes = [IsAuthenticatedOrTokenHasScope]
serializer_class = ChangePasswordSerializer
required_scopes = ["accounts.account:change_password"]
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():
pass_field = serializer.data.get("old_password_field")
old_password = serializer.data.get("old_password")
new_password = serializer.data.get("new_password")
if not self.object.check_auth(pass_field, 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)
class OTPLoginView(LoginView):
template_name = 'users/login.html'
"""
Display the login form and handle the login action.
"""
form_class = OTPAuthenticationForm
@method_decorator(login_required, name='dispatch')
class ProfileDetailView(DetailView):
model = User
@ -131,20 +43,3 @@ class ProfileDetailView(DetailView):
def get_object(self, queryset=None):
return self.request.user
@method_decorator(login_required, name='dispatch')
class ProfileUpdateView(UpdateView):
model = User
template_name = 'users/profile_update.html'
pk_url_kwarg = 'uuid'
form_class = ProfileUpdateForm
def get_success_url(self):
return reverse('users:profile_update')
def get_object(self, queryset=None):
return self.request.user
def form_valid(self, form):
return super().form_valid(form)

View file

@ -1,21 +1,4 @@
from django.contrib import admin
from .models import Transaction
class TransactionAdmin(admin.ModelAdmin):
fields = [
'uuid',
'amount',
'payer',
'payee',
'create',
'last_update',
'state',
'delay',
'detail_id',
'application'
]
readonly_fields = ['uuid', 'last_update', 'create', 'detail_id', 'application']
admin.site.register(Transaction, TransactionAdmin)
admin.site.register(Transaction)

View file

@ -1,9 +1,9 @@
# Generated by Django 4.1 on 2022-09-04 12:55
# Generated by Django 5.0.6 on 2024-07-03 14:33
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
@ -12,6 +12,7 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
]
operations = [
@ -24,8 +25,8 @@ class Migration(migrations.Migration):
('state', models.IntegerField(choices=[(1, 'created'), (2, 'delayed'), (3, 'pending'), (4, 'incomplete'), (5, 'success'), (6, 'failed'), (7, 'expected_failure')], default=1)),
('create', models.DateTimeField(auto_now=True, null=True, verbose_name='create')),
('last_update', models.DateTimeField(auto_now=True, null=True, verbose_name='last update')),
('application_client_id', models.CharField(db_index=True, max_length=100, null=True)),
('detail_id', models.IntegerField(blank=True, null=True)),
('application', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('payee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to=settings.AUTH_USER_MODEL)),
('payer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to=settings.AUTH_USER_MODEL)),
],

View file

@ -1,25 +0,0 @@
# Generated by Django 4.1 on 2022-09-08 12:16
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
('wallet', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='transaction',
name='application_client_id',
),
migrations.AddField(
model_name='transaction',
name='application',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
]

View file

@ -7,6 +7,7 @@ from django.db.models import F
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import APIException, ValidationError
from django.conf import settings
from oauth2_provider import settings as oauth2_settings
from .constans import StateChoices
from apps.users.models import User
@ -58,7 +59,7 @@ class Transaction(models.Model):
state = models.IntegerField(choices=StateChoices.choices, default=StateChoices.CREATED)
create = models.DateTimeField(_('create'), blank=True, null=True, auto_now=True)
last_update = models.DateTimeField(_('last update'), blank=True, null=True, auto_now=True)
application = models.ForeignKey(settings.OAUTH2_PROVIDER_APPLICATION_MODEL, on_delete=models.PROTECT,
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT,
related_name='transactions', null=True)
detail_id = models.IntegerField(null=True, blank=True) # TODO: or better detail url

View file

@ -3,15 +3,11 @@ from django.db.transaction import atomic
from rest_framework import serializers
from rest_framework.exceptions import APIException
from apps.users.serializers import PublicUserSerializer
from .models import Transaction
from ..users.models import User
class TransactionSerializer(serializers.ModelSerializer):
payer = PublicUserSerializer(required=False)
payee = PublicUserSerializer(required=False, read_only=True)
class Meta:
model = Transaction
fields = ('uuid',

View file

@ -9,7 +9,7 @@ from oauth2_provider.contrib.rest_framework import TokenHasScope, IsAuthenticate
from rest_framework import generics, permissions
from rest_framework.response import Response
from apps.accounts_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.wallet.models import Transaction
from apps.wallet.serializers import TransactionSerializer, DepositSerializer, WithdrawSerializer, WalletSerializer
from .constans import StateChoices

View file

@ -35,18 +35,7 @@
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item active">
<a class="nav-link" href="{% url 'superapp:app_list' %}">home <span class="sr-only"></span></a>
</li>
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'users:logout' %}">({{ request.user.phone_number }}) Logout</a>
</li>
{% endif %}
</ul>
</div>
</nav>
<div class="font-vazir container mb-3 mt-3">
{% include "include/message_frame.html" %}

View file

@ -52,8 +52,7 @@ INSTALLED_APPS = [
# local apps
'apps.users',
'apps.wallet',
'apps.accounts_oauth2',
'apps.superapp',
'apps.gooyal_oauth2',
]
MIDDLEWARE = [
@ -68,22 +67,25 @@ MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
]
# Tell Django OAuth Toolkit to use the Gooyal Application model
OAUTH2_PROVIDER_ID_TOKEN_MODEL = 'accounts_oauth2.IDToken'
OAUTH2_PROVIDER_APPLICATION_MODEL = 'accounts_oauth2.Application'
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'accounts_oauth2.AccessToken'
OAUTH2_PROVIDER_GRANT_MODEL = 'accounts_oauth2.Grant'
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = 'accounts_oauth2.RefreshToken'
OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application"
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken"
OAUTH2_PROVIDER_ID_TOKEN_MODEL = "oauth2_provider.IDToken"
OAUTH2_PROVIDER_GRANT_MODEL = "oauth2_provider.Grant"
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken"
OAUTH2_PROVIDER = {
# this is the list of available scopes
'SCOPES_BACKEND_CLASS': 'apps.accounts_oauth2.scopes.Scopes',
'SCOPES': {'read': 'Read scope',
'write': 'Write scope',
'groups': 'Access to your groups',
'introspection': 'Introspect token scope'},
'OAUTH2_VALIDATOR_CLASS': 'apps.accounts_oauth2.validators.OAuth2Validator',
# "INTROSPECTION_USER_CREATE_METHOD": 'apps.users.models.create_user_in_introspection',
# 'SCOPES_BACKEND_CLASS': 'apps.gooyal_oauth2.scopes.Scopes',
# 'SCOPES': {'read': 'Read scope',
# 'write': 'Write scope',
# 'groups': 'Access to your groups',
# 'introspection': 'Introspect token scope'},
'OAUTH2_VALIDATOR_CLASS': 'apps.gooyal_oauth2.validators.OAuth2Validator',
'RESOURCE_SERVER_INTROSPECTION_URL': 'https://accounts.gooyal.com/oauth2/introspect/',
# 'RESOURCE_SERVER_AUTH_TOKEN': '3yUqsWtwKYKHnfivFcJu', # OR this but not both:
'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': ('dCHm08iIoXvbRSgOI79SV6kIs0aoUzwJehtoczvJ','QBGwaye4Mvr2JHAtn5lQpZhd3RfSVw4XZNrgt6P4UBuV7u6IhsJXUV5QYv3v6rQYEuzjZdKYMjDQuXPmjHeF5UI5fFx080E7FPxFfYHIYBr3ZyvuPi1u7zDRF0EQbzbH'),
}
# GOOYAL_DYNAMIC_SCOPES
@ -99,7 +101,7 @@ REST_FRAMEWORK = {
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10,
'PAGE_SIZE': 20,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
@ -112,7 +114,7 @@ SPECTACULAR_SETTINGS = {
}
ROOT_URLCONF = 'accounts.urls'
ROOT_URLCONF = 'wallet.urls'
TEMPLATES = [
{
@ -132,7 +134,6 @@ TEMPLATES = [
]
AUTHENTICATION_BACKENDS = (
'apps.users.backends.OTPBackend',
'django.contrib.auth.backends.ModelBackend',
)

View file

@ -22,7 +22,7 @@ from rest_framework import permissions
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
# from apps.accounts_oauth2.views.introspect import introspect_token
# from apps.gooyal_oauth2.views.introspect import introspect_token
from apps.wallet.views import TransactionList, TransactionDetail, TransactionPay, TransactionReceipt, \
WithdrawVerify, WithdrawSubmit, DepositSubmit, DepositVerify
from django.contrib.auth import urls as auth_urls
@ -36,7 +36,6 @@ urlpatterns = [
path('admin/', admin.site.urls),
path('wallet/', include('apps.wallet.urls')),
path('users/', include('apps.users.urls')),
path('superapp/', include('apps.superapp.urls')),
path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')),