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 %} + + + + +
+
#}
+ {#
#}
+ {#