diff --git a/.gitignore b/.gitignore index 25a4f33..7bbac41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .python-version .idea media/* -delme*.py +utils/delme.py *.pyc .env /venv/ diff --git a/apps/users/forms.py b/apps/users/forms.py index 60b3411..a62ce67 100644 --- a/apps/users/forms.py +++ b/apps/users/forms.py @@ -1,8 +1,21 @@ from django import forms -from django.contrib.auth import (authenticate, get_user_model) +from django.contrib.auth import authenticate, get_user_model from django.contrib.auth.forms import UsernameField +from django.core.exceptions import ValidationError from django.utils.text import capfirst from django.utils.translation import gettext_lazy as _ +from .models import OAuthCode UserModel = get_user_model() + +class OAUTHLoginRequestForm(forms.ModelForm): + """ + Base class for authenticating users. Extend this to get a form that accepts + username/password logins. + """ + agreement = forms.BooleanField(required=True) + + class Meta: + model = OAuthCode + fields = ['agreement'] \ No newline at end of file diff --git a/apps/users/migrations/0002_oauthcode.py b/apps/users/migrations/0002_oauthcode.py new file mode 100644 index 0000000..27111c2 --- /dev/null +++ b/apps/users/migrations/0002_oauthcode.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.6 on 2024-07-10 12:48 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='OAuthCode', + fields=[ + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('code_verifier', models.CharField(blank=True, max_length=64, null=True)), + ('code', models.CharField(blank=True, max_length=64, null=True)), + ], + ), + ] diff --git a/apps/users/migrations/0003_alter_oauthcode_code_verifier.py b/apps/users/migrations/0003_alter_oauthcode_code_verifier.py new file mode 100644 index 0000000..f5c3eec --- /dev/null +++ b/apps/users/migrations/0003_alter_oauthcode_code_verifier.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-07-10 13:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_oauthcode'), + ] + + operations = [ + migrations.AlterField( + model_name='oauthcode', + name='code_verifier', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/apps/users/migrations/0004_alter_oauthcode_code_verifier.py b/apps/users/migrations/0004_alter_oauthcode_code_verifier.py new file mode 100644 index 0000000..5f9e9c8 --- /dev/null +++ b/apps/users/migrations/0004_alter_oauthcode_code_verifier.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-07-10 13:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_alter_oauthcode_code_verifier'), + ] + + operations = [ + migrations.AlterField( + model_name='oauthcode', + name='code_verifier', + field=models.CharField(blank=True, max_length=130, null=True), + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 65c53c9..e3f3824 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -2,6 +2,7 @@ import random import string from datetime import timedelta +import requests # import service_clients from django.conf import settings from django.contrib.auth.base_user import BaseUserManager @@ -11,6 +12,10 @@ from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ import uuid +import random +import string +import base64 +import hashlib @@ -74,3 +79,45 @@ class User(AbstractUser): def __str__(self): return str(self.username or self.pk) + + +# TODO: user redis instead. +# TODO: create a queryset +class OAuthCode(models.Model): + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) + code_verifier = models.CharField(max_length=130, blank=True, null=True) + code = models.CharField(max_length=64, blank=True, null=True) + + def generate_code_verifier(self): + code_verifier = ''.join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(random.randint(43, 128))) + self.code_verifier = code_verifier + print(len(code_verifier)) + self.save() + return code_verifier + + def generate_code_challenge(self): + code_challenge = hashlib.sha256(self.code_verifier.encode('utf-8')).digest() + code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '') + return code_challenge + + def generate_login_url(self): + url = f'''{settings.BASE_OAUTH2_PROVIDER_URL}/oauth2/authorize/?response_type=code&code_challenge={self.generate_code_challenge()}&code_challenge_method=S256&client_id={settings.CLIENT_ID}&scope=wallet.wallet:get_balance+wallet.transaction:list&state={self.uuid}''' + return url + + def validate_code(self): + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + data = { + "client_id": settings.CLIENT_ID, + "client_secret": settings.CLIENT_SECRET, + "code": self.code, + "code_verifier": self.code_verifier, + # "redirect_uri=http://127.0.0.1:8000/noexist/callback", + "grant_type": "authorization_code" + } + + response = requests.post(f'{settings.BASE_OAUTH2_PROVIDER_URL}/oauth2/token/', data=data, headers=headers) + + return response.json() \ No newline at end of file diff --git a/apps/users/urls.py b/apps/users/urls.py index b17be2f..31766e4 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -1,8 +1,10 @@ from django.urls import path -from .views import AccountView +from .views import AccountView, OAUTHLoginRequestView, OAUTHLoginCallbackView app_name = "users" urlpatterns = [ + path('login/request/', OAUTHLoginRequestView.as_view(), name='login_request'), + path('login/callback/', OAUTHLoginCallbackView.as_view(), name='login_calback'), path('api/account/', AccountView.as_view(), name='account_api'), ] diff --git a/apps/users/views.py b/apps/users/views.py index 79594c1..1af0abc 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -1,3 +1,11 @@ +from datetime import timedelta + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import login as auth_login +from django.http import HttpResponseRedirect +from django.utils.translation import gettext_lazy as _ + from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.contrib.auth.views import LoginView @@ -5,19 +13,23 @@ from django.shortcuts import render from django.urls import reverse from django.utils import timezone from django.utils.decorators import method_decorator -from django.views.generic import DetailView, UpdateView +from django.views.generic import DetailView, UpdateView, CreateView from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope from rest_framework import generics, status, permissions from rest_framework.response import Response from rest_framework.views import APIView from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements -from apps.users.models import User -from apps.users.serializers import AccountSerializer +from apps.gooyal_oauth2.validators import OAuth2Validator +from apps.users.forms import OAUTHLoginRequestForm +from apps.users.models import User, OAuthCode +from apps.users.serializers import AccountSerializer +from django.contrib.auth import login UserModel = get_user_model() +@method_decorator(login_required, name='dispatch') class AccountView(generics.RetrieveUpdateAPIView): serializer_class = AccountSerializer permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] @@ -43,3 +55,68 @@ class ProfileDetailView(DetailView): def get_object(self, queryset=None): return self.request.user + + +class OAUTHLoginRequestView(CreateView): + model = OAuthCode + form_class = OAUTHLoginRequestForm + template_name = 'users/login_request.html' + + def form_valid(self, form): + self.object: OAuthCode = form.save(commit=False) + self.object.generate_code_verifier() + self.object.save() + return HttpResponseRedirect(self.object.generate_login_url()) + + def form_invalid(self, form): + for error_key in form.errors: + for error_list in form.errors[error_key].data: + for error in error_list: + key = _(error_key) + messages.error(self.request, f"{key}: {error}") + + return super().form_invalid(form) + + +class OAUTHLoginCallbackView(DetailView): + model = OAuthCode + template_name = 'users/login_callback.html' + + def get_object(self, queryset=None): + state = self.request.GET.get('state') + code = self.request.GET.get('code') + obj = OAuthCode.objects.get(pk=state) + obj.code = code + obj.save() + return obj + + def introspect_token(self, token): + url = settings.OAUTH2_PROVIDER['RESOURCE_SERVER_INTROSPECTION_URL'] + credentials = settings.OAUTH2_PROVIDER['RESOURCE_SERVER_INTROSPECTION_CREDENTIALS'] + validator = OAuth2Validator() + access_token = validator._get_token_from_authentication_server(token, url, None, credentials) + return access_token + + def get_context_data(self, **kwargs): + data = self.object.validate_code() + # {'access_token': 'WhnIAfci6yIyTsh63PPqM1HXfXqKvr', 'expires_in': 36000, 'token_type': 'Bearer', + # 'scope': 'wallet.wallet:get_balance wallet.transaction:list', + # 'refresh_token': 'qRHFXXc3R3EiQKUq5BcSY2b9xuTm0d'} + # + # from oauth2_provider.models import get_access_token_model, get_application_model + access_token = self.introspect_token(data['access_token']) + login(self.request, access_token.user, backend='django.contrib.auth.backends.ModelBackend') + return super().get_context_data(**kwargs) + # AccessToken = get_access_token_model() + # access_token = AccessToken.objects.create( + # token=data['access_token'], + # scope=data['scope'], + # expires=timezone.now() + timedelta(data['scope']), + # ) + + + + + + print(data) + return super().get_context_data(**kwargs) diff --git a/templates/users/login_callback.html b/templates/users/login_callback.html new file mode 100755 index 0000000..075c839 --- /dev/null +++ b/templates/users/login_callback.html @@ -0,0 +1,41 @@ +{% load static %} +{% load crispy_forms_tags %} + + + + + + Login + + + + + + + + + + {# #} + + + +
+ {{ request.user }} +
+
+ {#
#} + {# #} + {#
#} + {#
#} + {#
#} +
+ {% include "include/messages.html" %} +
+
+
+
+ + +
+ + diff --git a/templates/users/login_request.html b/templates/users/login_request.html new file mode 100755 index 0000000..3daab12 --- /dev/null +++ b/templates/users/login_request.html @@ -0,0 +1,48 @@ +{% load static %} +{% load crispy_forms_tags %} + + + + + + Login + + + + + + + + + + {# #} + + + +
+
+
+ {#
#} + {# #} + {#
#} + {#
#} + {#
#} +
+ {% csrf_token %} + {{ form|crispy }} +
+ +
+ +
+
+ {% include "include/messages.html" %} +
+
+
+
+ + +
+ + diff --git a/wallet/settings.py b/wallet/settings.py index 6ba190d..b23517c 100644 --- a/wallet/settings.py +++ b/wallet/settings.py @@ -62,6 +62,7 @@ MIDDLEWARE = [ # 'django.middleware.locale.LocaleMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'oauth2_provider.middleware.OAuth2TokenMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'corsheaders.middleware.CorsMiddleware', @@ -74,7 +75,9 @@ OAUTH2_PROVIDER_ID_TOKEN_MODEL = "gooyal_oauth2.IDToken" OAUTH2_PROVIDER_GRANT_MODEL = "gooyal_oauth2.Grant" OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "gooyal_oauth2.RefreshToken" - +BASE_OAUTH2_PROVIDER_URL = "https://accounts.gooyal.com" +CLIENT_ID = 'dCHm08iIoXvbRSgOI79SV6kIs0aoUzwJehtoczvJ' +CLIENT_SECRET = 'QBGwaye4Mvr2JHAtn5lQpZhd3RfSVw4XZNrgt6P4UBuV7u6IhsJXUV5QYv3v6rQYEuzjZdKYMjDQuXPmjHeF5UI5fFx080E7FPxFfYHIYBr3ZyvuPi1u7zDRF0EQbzbH' OAUTH2_PROVIDER = { # this is the list of available scopes # 'SCOPES_BACKEND_CLASS': 'apps.gooyal_oauth2.scopes.Scopes', @@ -83,9 +86,9 @@ OAUTH2_PROVIDER = { # '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_INTROSPECTION_URL': BASE_OAUTH2_PROVIDER_URL + '/oauth2/introspect/', # 'RESOURCE_SERVER_AUTH_TOKEN': '3yUqsWtwKYKHnfivFcJu', # OR this but not both: - 'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': ('dCHm08iIoXvbRSgOI79SV6kIs0aoUzwJehtoczvJ','QBGwaye4Mvr2JHAtn5lQpZhd3RfSVw4XZNrgt6P4UBuV7u6IhsJXUV5QYv3v6rQYEuzjZdKYMjDQuXPmjHeF5UI5fFx080E7FPxFfYHIYBr3ZyvuPi1u7zDRF0EQbzbH'), + 'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': (CLIENT_ID, CLIENT_SECRET), } # GOOYAL_DYNAMIC_SCOPES @@ -119,8 +122,7 @@ ROOT_URLCONF = 'wallet.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'] - , + 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -134,6 +136,7 @@ TEMPLATES = [ ] AUTHENTICATION_BACKENDS = ( + 'oauth2_provider.backends.OAuth2Backend', 'django.contrib.auth.backends.ModelBackend', ) @@ -222,7 +225,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -LOGIN_URL = '/users/login/' +LOGIN_URL = '/users/login/request/' LOGIN_REDIRECT_URL = '/' CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"