From 7fcb62c27c202bf479f7c8c6d88116ef11135076 Mon Sep 17 00:00:00 2001 From: Nazarii Date: Thu, 1 Feb 2024 00:25:39 +0200 Subject: [PATCH] google oAuth2 integration --- .gitignore | 1 + build.sh | 171 +++++++++++++ docker/dev/env/.email.env | 2 +- docker/dev/env/.env | 6 +- docs/readme.md | 19 ++ web/api/email_service/__init__.py | 0 web/api/email_service/base.py | 72 ++++++ web/api/email_service/password_reset.py | 18 ++ web/api/email_service/sign_up.py | 17 ++ web/api/email_services.py | 38 --- web/api/v1/actions/serializers.py | 2 - web/api/v1/auth_app/managers.py | 66 +++++ web/api/v1/auth_app/oauth/__init__.py | 0 web/api/v1/auth_app/oauth/base/__init__.py | 0 web/api/v1/auth_app/oauth/base/client.py | 101 ++++++++ web/api/v1/auth_app/oauth/base/exceptions.py | 5 + web/api/v1/auth_app/oauth/base/factory.py | 25 ++ web/api/v1/auth_app/oauth/base/provider.py | 62 +++++ .../v1/auth_app/oauth/facebook/__init__.py | 0 .../v1/auth_app/oauth/facebook/provider.py | 6 + web/api/v1/auth_app/oauth/google/__init__.py | 0 web/api/v1/auth_app/oauth/google/provider.py | 75 ++++++ .../v1/auth_app/oauth/google/serializers.py | 7 + web/api/v1/auth_app/oauth/google/views.py | 19 ++ web/api/v1/auth_app/oauth/serializers.py | 7 + web/api/v1/auth_app/oauth/urls.py | 12 + web/api/v1/auth_app/oauth/views.py | 27 ++ web/api/v1/auth_app/serializers.py | 9 +- web/api/v1/auth_app/services.py | 234 ++++++++---------- web/api/v1/auth_app/types.py | 26 ++ web/api/v1/auth_app/urls.py | 4 +- web/api/v1/auth_app/views.py | 33 ++- web/api/v1/blog/serializers.py | 17 +- web/api/v1/blog/services.py | 30 ++- web/api/v1/blog/types.py | 14 ++ web/api/v1/blog/urls.py | 1 + web/api/v1/blog/views.py | 16 +- web/api/v1/profile/serializers.py | 14 +- web/api/v1/profile/services.py | 6 +- web/auth_app/migrations/0001_initial.py | 44 ++++ ...2_alter_socialaccount_provider_and_more.py | 27 ++ web/auth_app/migrations/__init__.py | 0 web/auth_app/models.py | 22 ++ .../static/auth_app/js/googleOauth.js | 46 ++++ web/auth_app/static/auth_app/js/login.js | 1 - .../static/auth_app/js/resetConfirm.js | 25 +- web/auth_app/static/auth_app/js/sign-up.js | 4 +- .../templates/auth_app/googleRedirect.html | 7 + web/auth_app/templates/auth_app/login.html | 5 + .../auth_app/reset_password_confirm.html | 2 +- web/auth_app/templates/auth_app/sign_up.html | 9 +- web/auth_app/urls.py | 1 + web/blog/models.py | 8 - web/blog/static/blog/js/post_create.js | 16 +- web/blog/templates/blog/post_create.html | 20 +- web/blog/templatetags/blog.py | 18 -- web/blog/urls.py | 3 +- web/blog/views.py | 34 --- web/conftest.py | 1 - web/main/managers.py | 2 +- web/main/models.py | 16 +- web/main/tasks.py | 1 - web/main/views.py | 41 --- web/src/additional_settings/__init__.py | 3 +- web/src/requirements/base.txt | 1 + web/src/settings.py | 29 ++- web/templates/includes/footer.html | 2 - .../static/user_profile/js/writeMessage.js | 4 +- web/user_profile/views.py | 2 +- 69 files changed, 1190 insertions(+), 366 deletions(-) create mode 100644 build.sh create mode 100644 docs/readme.md create mode 100644 web/api/email_service/__init__.py create mode 100644 web/api/email_service/base.py create mode 100644 web/api/email_service/password_reset.py create mode 100644 web/api/email_service/sign_up.py delete mode 100644 web/api/email_services.py create mode 100644 web/api/v1/auth_app/managers.py create mode 100644 web/api/v1/auth_app/oauth/__init__.py create mode 100644 web/api/v1/auth_app/oauth/base/__init__.py create mode 100644 web/api/v1/auth_app/oauth/base/client.py create mode 100644 web/api/v1/auth_app/oauth/base/exceptions.py create mode 100644 web/api/v1/auth_app/oauth/base/factory.py create mode 100644 web/api/v1/auth_app/oauth/base/provider.py create mode 100644 web/api/v1/auth_app/oauth/facebook/__init__.py create mode 100644 web/api/v1/auth_app/oauth/facebook/provider.py create mode 100644 web/api/v1/auth_app/oauth/google/__init__.py create mode 100644 web/api/v1/auth_app/oauth/google/provider.py create mode 100644 web/api/v1/auth_app/oauth/google/serializers.py create mode 100644 web/api/v1/auth_app/oauth/google/views.py create mode 100644 web/api/v1/auth_app/oauth/serializers.py create mode 100644 web/api/v1/auth_app/oauth/urls.py create mode 100644 web/api/v1/auth_app/oauth/views.py create mode 100644 web/api/v1/auth_app/types.py create mode 100644 web/api/v1/blog/types.py create mode 100644 web/auth_app/migrations/0001_initial.py create mode 100644 web/auth_app/migrations/0002_alter_socialaccount_provider_and_more.py create mode 100644 web/auth_app/migrations/__init__.py create mode 100644 web/auth_app/models.py create mode 100644 web/auth_app/static/auth_app/js/googleOauth.js create mode 100644 web/auth_app/templates/auth_app/googleRedirect.html delete mode 100644 web/blog/templatetags/blog.py delete mode 100644 web/blog/views.py diff --git a/.gitignore b/.gitignore index 1ca0570d..3e86709a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ redis-*.tgz db.sqlite3 .secrets.env *.spec +.secrets.env diff --git a/build.sh b/build.sh new file mode 100644 index 00000000..3d2621f1 --- /dev/null +++ b/build.sh @@ -0,0 +1,171 @@ +#!/bin/bash + + +help() { + echo "" + echo "Usage: build.sh [--linter ] [--build ] [--test]" + echo "-l, --linter Linter to run (black, isort, flake8)" + echo "-b, --build Build type (frontend, backend)" + echo "-h, --help Print this help message" + echo "-t, --test Test type (backend)" + echo +} + +install_dependencies() { + pip install -r requirements.txt +} + +run_black() { + echo "Running Black..." + black --check --diff . +} + +run_isort() { + echo "Running isort..." + isort --check --diff . +} + +run_flake8() { + echo "Running Flake8..." + flake8 . +} + + +validate_image_args() { + IMAGE_NAME=${IMAGE_NAME:-$CI_REGISTRY_IMAGE} # "registry.gitlab.com/mds7060534/naivebayes_app/backend" + APP_VERSION=${APP_VERSION:-$CI_COMMIT_REF_SLUG} + PROJECT_PATH=${build_path} + + if [ -z "${IMAGE_NAME}" ]; then + echo "Docker image not specified. Please provide -i | --image-name" + echo "Supported env vars: DOCKER_IMAGE | CI_REGISTRY_IMAGE" + exit 1 + fi + + if [ -z "${APP_VERSION}" ]; then + echo "Docker image not specified. Please provide -v | --version" + echo "Supported env vars: APP_VERSION | CI_COMMIT_REF_SLUG" + exit 1 + fi + + IMAGE=${IMAGE_NAME}/${PROJECT_PATH}:${APP_VERSION} + + echo "Image Name: ${IMAGE_NAME}" + echo "App Version: ${APP_VERSION}" + echo "Project Path: ${PROJECT_PATH}" + echo "Image: ${IMAGE}" +} + +run_build() { + + validate_image_args + + CI_PROJECT_DIR=$(pwd) + DOCKERFILE_PATH="./Dockerfile" + CONTEXT="${CI_PROJECT_DIR}/${PROJECT_PATH}" + + /kaniko/executor --context "${CONTEXT}" --dockerfile "${DOCKERFILE_PATH}" --destination "${IMAGE_NAME}" +} + +run_test() { + validate_image_args + + docker pull $IMAGE + docker run --entrypoint="" --rm --name $PROJECT_PATH $IMAGE python -m coverage run -m unittest discover -s tests -p *_test.py +} + +# Main function to execute linting tasks based on the specified linter + +if [ $# -eq 0 ]; then + echo "No arguments provided. Usage: ./build.sh --help" + exit 1 +fi + + +main() { + while [[ $# -gt 0 ]]; do + key="$1" + case "$key" in + -l|--linter) + linter="$2" + shift; shift; + ;; + -b|--build) + build="$2" + build_path="$2" + shift; shift; + ;; + -t|--test) + test="$2" + build_path="$2" + shift; shift; + ;; + -h|--help) + help + exit 0; + ;; + -i|--image-name) + IMAGE_NAME="$2" + shift; shift; + ;; + -v|--version) + APP_VERSION="$2" + shift; shift; + ;; + *) + echo "Invalid argument: $key" + help + exit 1 + ;; + esac + done +} + + +main "$@" + + +if [ "$linter" ]; then + case "$linter" in + black) + run_black "$@" + ;; + isort) + run_isort "$@" + ;; + flake8) + run_flake8 "$@" + ;; + *) + echo "Invalid linter: $linter" + exit 1 + ;; + esac +fi + +if [ "$build" ]; then + case "$build" in + frontend) + run_build "$@" + ;; + backend) + run_build "$@" + ;; + *) + echo "Invalid build: $build" + exit 1 + ;; + esac +fi + +if [ "$test" ]; then + case "$test" in + backend) + run_test "$@" + ;; + *) + echo "Invalid test: $test" + exit 1 + ;; + esac +fi diff --git a/docker/dev/env/.email.env b/docker/dev/env/.email.env index a5da6019..acbad039 100644 --- a/docker/dev/env/.email.env +++ b/docker/dev/env/.email.env @@ -1,4 +1,4 @@ -EMAIL_HOST=gateway-host +EMAIL_HOST=host.docker.internal EMAIL_PORT=1025 EMAIL_USE_TLS=0 EMAIL_USE_SSL=0 diff --git a/docker/dev/env/.env b/docker/dev/env/.env index d2326f33..f41d7391 100644 --- a/docker/dev/env/.env +++ b/docker/dev/env/.env @@ -10,7 +10,7 @@ HEALTH_CHECK_URL=/application/health/ ENABLE_SILK=1 ENABLE_DEBUG_TOOLBAR=0 ENABLE_SENTRY=0 -FRONTEND_URL=http://192.168.59.111:8008 -BACKEND_URL=http://192.168.59.111:8008 +FRONTEND_URL=http://localhost:8008 +BACKEND_URL=http://localhost:8008 -CHAT_PROXY=http://192.168.59.111:8005/init/ +CHAT_PROXY=http://localhost:8005/init/ diff --git a/docs/readme.md b/docs/readme.md new file mode 100644 index 00000000..22540971 --- /dev/null +++ b/docs/readme.md @@ -0,0 +1,19 @@ +* Install dependencies +```shell +pipenv install -r docs/requirements.txt --dev +``` + +* doc8 style checks +```shell +doc8 docs/source/ --config web/pyproject.toml +``` + +* Generate documentation +```shell +make -C docs html +``` + +* Check spelling +```shell +make -C docs spelling +``` diff --git a/web/api/email_service/__init__.py b/web/api/email_service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/email_service/base.py b/web/api/email_service/base.py new file mode 100644 index 00000000..1d433380 --- /dev/null +++ b/web/api/email_service/base.py @@ -0,0 +1,72 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Optional, TypedDict + +from django.contrib.auth import get_user_model +from django.utils.translation import get_language + +from main.tasks import send_information_email + +if TYPE_CHECKING: + from main.models import UserType + + +User: 'UserType' = get_user_model() + + +class EmailSendData(TypedDict): + subject: str + template_name: str + language: str + to_email: str + context: dict + from_email: Optional[str] + file_path_attachments: Optional[str] + + +class BaseEmailService(ABC): + def __init__(self, user: Optional[User] = None, language: Optional[str] = None, from_email: Optional[str] = None): + self._user = user + self._locale: str = language or get_language() + self.from_email = from_email + + @property + def locale(self) -> str: + return self._locale + + @property + def user(self) -> User: + assert self._user, 'User object were not provided' + return self._user + + @property + @abstractmethod + def template_name(self) -> str: + """Template email path""" + + @property + def to_email(self) -> str: + return self.user.email + + def email_context(self, **kwargs) -> dict: + """Provide dict with data for email template rendering""" + return {} + + @property + @abstractmethod + def email_subject(self) -> str: + """Provide email subject""" + + def get_email_data(self, **kwargs) -> EmailSendData: + return { + 'subject': self.email_subject, + 'template_name': self.template_name, + 'language': self.locale, + 'to_email': self.to_email, + 'context': self.email_context(**kwargs), + 'from_email': self.from_email, + 'file_path_attachments': None, + } + + def send_email(self, **kwargs): + kwargs: dict = self.get_email_data(**kwargs) + return send_information_email.apply_async(kwargs=kwargs) diff --git a/web/api/email_service/password_reset.py b/web/api/email_service/password_reset.py new file mode 100644 index 00000000..bb707eca --- /dev/null +++ b/web/api/email_service/password_reset.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from api.email_service.base import BaseEmailService + + +class PasswordResetService(BaseEmailService): + template_name = 'emails/password_reset.html' + + @property + def email_subject(self) -> str: + return _('Blog password reset') + + def email_context(self, reset_url: str, **kwargs) -> dict: + return { + 'full_name': self.user.full_name, + 'reset_url': reset_url, + } diff --git a/web/api/email_service/sign_up.py b/web/api/email_service/sign_up.py new file mode 100644 index 00000000..e04c522f --- /dev/null +++ b/web/api/email_service/sign_up.py @@ -0,0 +1,17 @@ +from django.utils.translation import gettext_lazy as _ + +from api.email_service.base import BaseEmailService + + +class SignUpEmailService(BaseEmailService): + template_name = 'emails/verify_email.html' + + @property + def email_subject(self) -> str: + return _('Blog register confirmation email') + + def email_context(self, activate_url: str, **kwargs) -> dict: + return { + 'full_name': self.user.full_name, + 'activate_url': activate_url, + } diff --git a/web/api/email_services.py b/web/api/email_services.py deleted file mode 100644 index 6e01eab8..00000000 --- a/web/api/email_services.py +++ /dev/null @@ -1,38 +0,0 @@ -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Optional - -from django.contrib.auth import get_user_model -from django.utils.translation import get_language - -from main.tasks import send_information_email - -if TYPE_CHECKING: - from main.models import UserType - - -User: 'UserType' = get_user_model() - - -class BaseEmailHandler(ABC): - TEMPLATE_NAME: str = NotImplemented - - def __init__(self, user: Optional[User] = None, language: Optional[str] = None): - self.user = user - self._locale: str = language or get_language() - - @property - def locale(self) -> str: - return self._locale - - def send_email(self): - kwargs = self.email_kwargs() - default_kwargs = { - 'template_name': self.TEMPLATE_NAME, - 'letter_language': self.locale, - } - kwargs.update(default_kwargs) - return send_information_email.apply_async(kwargs=kwargs) - - @abstractmethod - def email_kwargs(self, **kwargs) -> dict: - """Provide a dict with at least subject, to_email and context keys""" diff --git a/web/api/v1/actions/serializers.py b/web/api/v1/actions/serializers.py index 3bbd81f2..8e72c391 100644 --- a/web/api/v1/actions/serializers.py +++ b/web/api/v1/actions/serializers.py @@ -25,8 +25,6 @@ class FollowSerializer(serializers.Serializer): class UserFollowSerializer(serializers.ModelSerializer): - """For list of user following and followers""" - profile_url = serializers.URLField(source='get_absolute_url') follow = serializers.BooleanField() diff --git a/web/api/v1/auth_app/managers.py b/web/api/v1/auth_app/managers.py new file mode 100644 index 00000000..171b6e2d --- /dev/null +++ b/web/api/v1/auth_app/managers.py @@ -0,0 +1,66 @@ +from typing import TYPE_CHECKING, Optional + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import default_token_generator +from django.core import signing +from django.utils.encoding import force_bytes, force_str +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from rest_framework.exceptions import ValidationError + +from api.v1.auth_app.types import PasswordResetDTO + +if TYPE_CHECKING: + from main.models import UserType + + +User: 'UserType' = get_user_model() + + +class ConfirmationKeyManager: + def __init__(self): + self.max_age = 60 * 60 * 24 * settings.EMAIL_CONFIRMATION_EXPIRE_DAYS + + @staticmethod + def generate_key(user: User) -> str: + return signing.dumps(obj=user.pk) + + def get_user_from_key(self, key: str) -> Optional[User]: + try: + pk = signing.loads(key, max_age=self.max_age) + user = User.objects.get(id=pk) + except (signing.SignatureExpired, signing.BadSignature, User.DoesNotExist): + user = None + return user + + +class PasswordResetManager: + def __init__(self): + self.token_generator = default_token_generator + + def generate(self, user: User) -> PasswordResetDTO: + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = self.token_generator.make_token(user) + return PasswordResetDTO(uid=uid, token=token) + + def validate(self, uid: str, token: str, raise_exception: bool = True) -> User: + errors = [] + user = self._get_user_by_uid(uid) + if not user: + errors.append({'uid': ['Invalid value']}) + if user and not self._validate_token(user, token): + errors.append({'token': ['Invalid value']}) + if errors and raise_exception: + raise ValidationError(errors) + return user + + @staticmethod + def _get_user_by_uid(uid: str) -> Optional[User]: + try: + uid = force_str(urlsafe_base64_decode(uid)) + return User.objects.get(id=uid) + except (User.DoesNotExist, ValueError): + return None + + def _validate_token(self, user: User, token: str) -> bool: + return self.token_generator.check_token(user, token) diff --git a/web/api/v1/auth_app/oauth/__init__.py b/web/api/v1/auth_app/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/v1/auth_app/oauth/base/__init__.py b/web/api/v1/auth_app/oauth/base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/v1/auth_app/oauth/base/client.py b/web/api/v1/auth_app/oauth/base/client.py new file mode 100644 index 00000000..6b3bdc17 --- /dev/null +++ b/web/api/v1/auth_app/oauth/base/client.py @@ -0,0 +1,101 @@ +from urllib.parse import parse_qsl + +import requests +from django.utils.http import urlencode + +from api.v1.auth_app.oauth.base.exceptions import OAuth2Error + + +class OAuth2Client: + def __init__( + self, + request, + client_id, + client_secret, + access_token_method, + access_token_url, + callback_url, + scope, + scope_delimiter=" ", + headers=None, + basic_auth=False, + ): + self.request = request + self.access_token_method = access_token_method + self.access_token_url = access_token_url + self.callback_url = callback_url + self.client_id = client_id + self.client_secret = client_secret + self.scope = scope_delimiter.join(set(scope)) + self.state = None + self.headers = headers + self.basic_auth = basic_auth + + def get_redirect_url(self, authorization_url, extra_params): + params = { + "client_id": self.client_id, + "redirect_uri": self.callback_url, + "scope": self.scope, + "response_type": "code", + } + if self.state: + params["state"] = self.state + params.update(extra_params) + return "%s?%s" % (authorization_url, urlencode(params)) + + def get_access_token(self, code, pkce_code_verifier=None): + data = { + "redirect_uri": self.callback_url, + "grant_type": "authorization_code", + "code": code, + } + if self.basic_auth: + auth = requests.auth.HTTPBasicAuth(self.client_id, self.client_secret) + else: + auth = None + data.update( + { + "client_id": self.client_id, + "client_secret": self.client_secret, + } + ) + params = None + self._strip_empty_keys(data) + url = self.access_token_url + if self.access_token_method == "GET": + params = data + data = None + if data and pkce_code_verifier: + data["code_verifier"] = pkce_code_verifier + # TODO: Proper exception handling + resp = ( + get_adapter() + .get_requests_session() + .request( + self.access_token_method, + url, + params=params, + data=data, + headers=self.headers, + auth=auth, + ) + ) + + access_token = None + if resp.status_code in [200, 201]: + # Weibo sends json via 'text/plain;charset=UTF-8' + if resp.headers["content-type"].split(";")[0] == "application/json" or resp.text[:2] == '{"': + access_token = resp.json() + else: + access_token = dict(parse_qsl(resp.text)) + if not access_token or "access_token" not in access_token: + raise OAuth2Error("Error retrieving access token: %s" % resp.content) + return access_token + + def _strip_empty_keys(self, params): + """Added because the Dropbox OAuth2 flow doesn't + work when scope is passed in, which is empty. + """ + keys = [k for k, v in params.items() if v == ""] + for key in keys: + del params[key] diff --git a/web/api/v1/auth_app/oauth/base/exceptions.py b/web/api/v1/auth_app/oauth/base/exceptions.py new file mode 100644 index 00000000..8eaca1fa --- /dev/null +++ b/web/api/v1/auth_app/oauth/base/exceptions.py @@ -0,0 +1,5 @@ +from rest_framework.exceptions import ValidationError + + +class OAuth2Error(ValidationError): + pass diff --git a/web/api/v1/auth_app/oauth/base/factory.py b/web/api/v1/auth_app/oauth/base/factory.py new file mode 100644 index 00000000..34eb54bd --- /dev/null +++ b/web/api/v1/auth_app/oauth/base/factory.py @@ -0,0 +1,25 @@ +from collections import OrderedDict +from typing import Type + +from .provider import OAuth2Provider + + +class ProviderRegistry(object): + def __init__(self): + self.provider_map = OrderedDict() + self.loaded = False + + def get_classes(self): + return [{'name': name} for name, cls in self.provider_map.items() if cls().enabled] + + def register(self, cls): + self.provider_map[cls.name] = cls + + def get_class(self, provider_name: str) -> Type[OAuth2Provider]: + return self.provider_map.get(provider_name) + + def as_choices(self): + return [(provider_cls.name, provider_cls.name.capitalize()) for provider_cls in self.provider_map.values()] + + +provider_registry = ProviderRegistry() diff --git a/web/api/v1/auth_app/oauth/base/provider.py b/web/api/v1/auth_app/oauth/base/provider.py new file mode 100644 index 00000000..7c434ad6 --- /dev/null +++ b/web/api/v1/auth_app/oauth/base/provider.py @@ -0,0 +1,62 @@ +from abc import abstractmethod +from typing import TypeVar +from uuid import uuid4 + +from django.conf import settings + +from auth_app.models import SocialAccountProvider + + +class OAuth2Provider: + name: SocialAccountProvider = '' + + def __str__(self): + return self.name + + @property + def headers(self) -> dict: + return { + 'Content-Type': 'application/x-www-form-urlencoded', + } + + def authorization_header(self, token: str) -> dict: + return { + 'Authorization': f'Bearer {token}', + } + + def __init__(self): + self._name: str = self.name.upper() + self.config = settings.SOCIAL_ACCOUNTS_PROVIDERS.get(self.name) + self.enabled = self.config.get('enabled', True) + self.client_id = self.config['client_id'] + self.client_secret = self.config['client_secret'] + + @abstractmethod + def get_access_token(self, code: str): + pass + + @abstractmethod + def get_user_info(self, access_token: str): + pass + + @abstractmethod + def get_redirect_url(self, request): + pass + + def setup_state(self, request) -> str: + state = uuid4().hex + request.session[self._name] = {} + request.session[self._name]['state'] = state + return state + + def validate_state(self, request, expected_state: str) -> bool: + if self._name not in request.session: + return False + if 'state' not in request.session[self._name]: + return False + if request.session[self._name]['state'] != expected_state: + return False + return True + + +OAuth2ProviderT = TypeVar('OAuth2ProviderT', bound=OAuth2Provider) diff --git a/web/api/v1/auth_app/oauth/facebook/__init__.py b/web/api/v1/auth_app/oauth/facebook/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/v1/auth_app/oauth/facebook/provider.py b/web/api/v1/auth_app/oauth/facebook/provider.py new file mode 100644 index 00000000..3f74d104 --- /dev/null +++ b/web/api/v1/auth_app/oauth/facebook/provider.py @@ -0,0 +1,6 @@ +from api.v1.auth_app.oauth.base.provider import OAuth2Provider +from auth_app.models import SocialAccountProvider + + +class FacebookProvider(OAuth2Provider): + name = SocialAccountProvider.FACEBOOK diff --git a/web/api/v1/auth_app/oauth/google/__init__.py b/web/api/v1/auth_app/oauth/google/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/v1/auth_app/oauth/google/provider.py b/web/api/v1/auth_app/oauth/google/provider.py new file mode 100644 index 00000000..b24dba16 --- /dev/null +++ b/web/api/v1/auth_app/oauth/google/provider.py @@ -0,0 +1,75 @@ +from typing import NamedTuple +from urllib.parse import urlencode + +import requests +from django.conf import settings + +from api.v1.auth_app.oauth.base.exceptions import OAuth2Error +from api.v1.auth_app.oauth.base.factory import provider_registry +from api.v1.auth_app.oauth.base.provider import OAuth2Provider +from auth_app.models import SocialAccountProvider + + +class GoogleTokenData(NamedTuple): + access_token: str + expires_in: int + scope: str + token_type: str + id_token: str + + +class GoogleUserInfo(NamedTuple): + id: str + email: str + verified_email: bool + name: str + given_name: str + family_name: str + picture: str + locale: str + + +class GoogleProvider(OAuth2Provider): + name = SocialAccountProvider.GOOGLE + + def __init__(self): + super().__init__() + self.auth_redirect_url = 'https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount' + self.access_token_url = 'https://oauth2.googleapis.com/token' + self.authorize_url = 'https://accounts.google.com/o/oauth2/v2/auth' + self.user_info = 'https://www.googleapis.com/oauth2/v2/userinfo' + self.redirect_uri = settings.GOOGLE_REDIRECT_URI + + def get_access_token(self, code: str) -> GoogleTokenData: + data = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': self.redirect_uri, + 'grant_type': 'authorization_code', + } + + response = requests.post(self.access_token_url, data=data, headers=self.headers) + if response.status_code != 200: + raise OAuth2Error() + return GoogleTokenData(**response.json()) + + def get_user_info(self, access_token: str) -> GoogleUserInfo: + response = requests.get(self.user_info, headers=self.authorization_header(access_token)) + if response.status_code != 200: + raise OAuth2Error() + return GoogleUserInfo(**response.json()) + + def get_redirect_url(self, request) -> str: + state = self.setup_state(request) + data = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'openid email profile', + 'state': state, + } + return f'{self.auth_redirect_url}?{urlencode(data)}' + + +provider_registry.register(GoogleProvider) diff --git a/web/api/v1/auth_app/oauth/google/serializers.py b/web/api/v1/auth_app/oauth/google/serializers.py new file mode 100644 index 00000000..477a9e0f --- /dev/null +++ b/web/api/v1/auth_app/oauth/google/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class GoogleLoginSerializer(serializers.Serializer): + code = serializers.CharField() + state = serializers.CharField() + scope = serializers.CharField() diff --git a/web/api/v1/auth_app/oauth/google/views.py b/web/api/v1/auth_app/oauth/google/views.py new file mode 100644 index 00000000..7f77920b --- /dev/null +++ b/web/api/v1/auth_app/oauth/google/views.py @@ -0,0 +1,19 @@ +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from api.v1.auth_app.services import OAuthLoginService + +from .provider import GoogleProvider +from .serializers import GoogleLoginSerializer + + +class GoogleOAuth2CallbackView(GenericAPIView): + permission_classes = (AllowAny,) + serializer_class = GoogleLoginSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + service = OAuthLoginService(request, GoogleProvider()) + return service.login(code=serializer.data['code'], state=serializer.data['state']) diff --git a/web/api/v1/auth_app/oauth/serializers.py b/web/api/v1/auth_app/oauth/serializers.py new file mode 100644 index 00000000..018db1aa --- /dev/null +++ b/web/api/v1/auth_app/oauth/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + +from api.v1.auth_app.oauth.base.factory import provider_registry + + +class OAuth2RedirectSerializer(serializers.Serializer): + provider = serializers.ChoiceField(choices=provider_registry.as_choices()) diff --git a/web/api/v1/auth_app/oauth/urls.py b/web/api/v1/auth_app/oauth/urls.py new file mode 100644 index 00000000..44b79d7d --- /dev/null +++ b/web/api/v1/auth_app/oauth/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views +from .google.views import GoogleOAuth2CallbackView + +app_name = 'oauth_app' + +urlpatterns = [ + path('oauth2/redirect-url/', views.OAuth2RedirectView.as_view(), name='oauth2-redirect-url'), + path('oauth2/providers/', views.OAuth2ProviderListView.as_view(), name='oauth2-providers'), + path('google/sign-in/', GoogleOAuth2CallbackView.as_view(), name='google-sign-in'), +] diff --git a/web/api/v1/auth_app/oauth/views.py b/web/api/v1/auth_app/oauth/views.py new file mode 100644 index 00000000..5f95cae8 --- /dev/null +++ b/web/api/v1/auth_app/oauth/views.py @@ -0,0 +1,27 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from rest_framework.views import APIView + +from . import serializers +from .base.factory import provider_registry + + +class OAuth2RedirectView(APIView): + permission_classes = () + + @extend_schema(parameters=[serializers.OAuth2RedirectSerializer]) + def get(self, request): + serializer = serializers.OAuth2RedirectSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + provider = serializer.data['provider'] + provider = provider_registry.get_class(provider) + redirect_url: str = provider().get_redirect_url(request) + return Response(redirect_url) + + +class OAuth2ProviderListView(APIView): + permission_classes = () + + def get(self, request): + providers = provider_registry.get_classes() + return Response(providers) diff --git a/web/api/v1/auth_app/serializers.py b/web/api/v1/auth_app/serializers.py index c7ef517b..c52e7084 100644 --- a/web/api/v1/auth_app/serializers.py +++ b/web/api/v1/auth_app/serializers.py @@ -51,12 +51,15 @@ class PasswordResetSerializer(serializers.Serializer): email = serializers.EmailField() -class PasswordResetConfirmSerializer(serializers.Serializer): - password_1 = serializers.CharField(min_length=8, max_length=64) - password_2 = serializers.CharField(min_length=8, max_length=64) +class PasswordResetVerifySerializer(serializers.Serializer): uid = serializers.CharField() token = serializers.CharField() + +class PasswordResetConfirmSerializer(PasswordResetVerifySerializer): + password_1 = serializers.CharField(min_length=8, max_length=64) + password_2 = serializers.CharField(min_length=8, max_length=64) + def validate_password_1(self, password: str) -> str: validate_password(password) return password diff --git a/web/api/v1/auth_app/services.py b/web/api/v1/auth_app/services.py index 7f3c9f1a..7a4aca34 100644 --- a/web/api/v1/auth_app/services.py +++ b/web/api/v1/auth_app/services.py @@ -1,15 +1,13 @@ -from datetime import date -from typing import TYPE_CHECKING, NamedTuple -from urllib.parse import urlencode, urljoin +from typing import TYPE_CHECKING +from urllib.parse import quote, urlencode, urljoin import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model -from django.contrib.auth.tokens import default_token_generator +from django.core.files.base import ContentFile from django.db import transaction +from django.db.models import Q from django.utils import timezone -from django.utils.encoding import force_bytes, force_str -from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ from rest_framework import status from rest_framework.exceptions import ValidationError @@ -19,123 +17,74 @@ from rest_framework_simplejwt.settings import api_settings as jwt_settings from rest_framework_simplejwt.tokens import RefreshToken -from api.email_services import BaseEmailHandler +from api.email_service.password_reset import PasswordResetService +from api.email_service.sign_up import SignUpEmailService +from api.v1.auth_app.oauth.base.provider import OAuth2Provider +from api.v1.auth_app.oauth.google.provider import GoogleTokenData, GoogleUserInfo from api.v1.auth_app.utils import LoginResponseSerializer, get_client_ip +from auth_app.models import SocialAccount +from .managers import ConfirmationKeyManager, PasswordResetManager +from .oauth.base.exceptions import OAuth2Error +from .types import CreateUserData, PasswordResetConfirmData from main.decorators import except_shell -from main.models import GenderChoice -from main.tasks import send_information_email if TYPE_CHECKING: + from django.http import HttpResponse + from main.models import UserType User: 'UserType' = get_user_model() -class PasswordResetConfirmData(NamedTuple): - uid: str - token: str - password_1: str - password_2: str +class PasswordResetHandler: + def __init__(self, email: dict): + self.email = email + self.frontend_url = settings.FRONTEND_URL + self.frontend_path = '/reset/confirm' + def reset_password(self): + user = User.objects.get(email=self.email) + reset_url = self._get_reset_url(user) + PasswordResetService(user).send_email(reset_url=reset_url) -class CreateUserData(NamedTuple): - first_name: str - last_name: str - email: str - password_1: str - password_2: str - birthday: date = None - gender: GenderChoice = None + def _get_reset_url(self, user) -> str: + values = PasswordResetManager().generate(user) + url = urljoin(self.frontend_url, self.frontend_path) + reset_url = f'{url}?uid={values.uid}&token={values.token}' + return quote(reset_url, safe=':/?&=') -class ConfirmationEmailHandler(BaseEmailHandler): - FRONTEND_URL = settings.FRONTEND_URL - FRONTEND_PATH = '/confirm' - TEMPLATE_NAME = 'emails/verify_email.html' +class SignUpHandler: + def __init__(self, validated_data: dict): + self.data = CreateUserData(**validated_data) + self.frontend_url = settings.FRONTEND_URL + self.frontend_path = '/confirm' - def _get_activate_url(self) -> str: - url = urljoin(self.FRONTEND_URL, self.FRONTEND_PATH) - query_params: str = urlencode( - { - 'key': self.user.confirmation_key, - }, - safe=':+', + @transaction.atomic() + def create_user(self): + user = User.objects.create_user( + email=self.data.email, + first_name=self.data.first_name, + last_name=self.data.last_name, + password=self.data.password_1, + birthday=self.data.birthday, + gender=self.data.gender, + is_active=False, ) - return f'{url}?{query_params}' - - def email_kwargs(self, **kwargs) -> dict: - return { - 'subject': _('Register confirmation email'), - 'to_email': self.user.email, - 'context': { - 'user': self.user.full_name, - 'activate_url': self._get_activate_url(), - }, - } - + activate_url = self._get_activate_url(user) + SignUpEmailService(user).send_email(activate_url=activate_url) -class PasswordReset(BaseEmailHandler): - FRONTEND_URL = settings.FRONTEND_URL - TEMPLATE_NAME = 'emails/password_reset.html' - FRONTEND_PATH = '/reset/confirm' - - def _get_reset_url(self, uid: str, token: str) -> str: - url = urljoin(self.FRONTEND_URL, self.FRONTEND_PATH) + def _get_activate_url(self, user) -> str: + url = urljoin(self.frontend_url, self.frontend_path) query_params: str = urlencode( { - 'uid': uid, - 'token': token, + 'key': ConfirmationKeyManager.generate_key(user), }, safe=':+', ) return f'{url}?{query_params}' - def email_kwargs(self, **kwargs) -> dict: - uid = urlsafe_base64_encode(force_bytes(self.user.pk)) - token = default_token_generator.make_token(self.user) - reset_url = self._get_reset_url(uid=uid, token=token) - return { - 'subject': _('Password Reset'), - 'to_email': self.user.email, - 'context': { - 'user': self.user.full_name, - 'reset_url': reset_url, - }, - } - - -class PasswordResetConfirmHandler: - def __init__(self, *, uid: str, token: str): - self._token = token - self._uid = uid - self._user = None - - @property - def user(self) -> User: - return self._user - - def validate(self): - self._user = self._get_user_by_uid(self._uid) - self._validate_token() - - @staticmethod - def _get_user_by_uid(uid: str) -> User: - try: - uid = force_str(urlsafe_base64_decode(uid)) - return User.objects.get(id=uid) - except ( - User.DoesNotExist, - OverflowError, - TypeError, - ValueError, - ): - raise ValidationError({'uid': ['Invalid value']}) - - def _validate_token(self): - if not default_token_generator.check_token(self.user, self._token): - raise ValidationError({'token': ['Invalid value']}) - class LoginService: response_serializer = LoginResponseSerializer @@ -209,11 +158,11 @@ def __set_jwt_cookie(self, response, key: str, token_value: str, token_expiratio domain=self.rest_settings['JWT_COOKIE_DOMAIN'], ) - def _set_jwt_access_cookie(self, response, access_token): + def _set_jwt_access_cookie(self, response: "HttpResponse", access_token: str): access_token_expiration = timezone.now() + jwt_settings.ACCESS_TOKEN_LIFETIME self.__set_jwt_cookie(response, self.rest_settings['JWT_AUTH_COOKIE'], access_token, access_token_expiration) - def _set_jwt_refresh_cookie(self, response, refresh_token): + def _set_jwt_refresh_cookie(self, response: "HttpResponse", refresh_token: str): refresh_token_expiration = timezone.now() + jwt_settings.REFRESH_TOKEN_LIFETIME self.__set_jwt_cookie( response, self.rest_settings['JWT_AUTH_REFRESH_COOKIE'], refresh_token, refresh_token_expiration @@ -243,42 +192,24 @@ def validate_captcha(captcha: str, request) -> tuple: def get_user(email: str) -> User: return User.objects.get(email=email) - def password_reset(self, email: str) -> None: - user = self.get_user(email) - if not user: - return - PasswordReset(user).send_email() - - def password_reset_confirm(self, validated_data: dict) -> None: + @staticmethod + def password_reset_confirm(validated_data: dict) -> None: data = PasswordResetConfirmData(**validated_data) - handler = PasswordResetConfirmHandler(token=data.token, uid=data.uid) - handler.validate() - user = handler.user + manager = PasswordResetManager() + user = manager.validate(token=data.token, uid=data.uid) user.set_password(data.password_1) user.save(update_fields=['password']) - def verify_email_confirm(self, key: str): - user = User.from_key(key) + @staticmethod + def verify_email_confirm(key: str) -> User: + user = ConfirmationKeyManager().get_user_from_key(key) if not user: raise ValidationError({'key': _('Invalid or expired confirmation key')}) if user.is_active: raise ValidationError({'key': _('User already verified')}) user.is_active = True user.save(update_fields=['is_active']) - - @transaction.atomic() - def create_user(self, validated_data: dict): - data = CreateUserData(**validated_data) - user = User.objects.create_user( - email=data.email, - first_name=data.first_name, - last_name=data.last_name, - password=data.password_1, - birthday=data.birthday, - gender=data.gender, - is_active=False, - ) - ConfirmationEmailHandler(user).send_email() + return user def full_logout(request): @@ -317,3 +248,52 @@ def full_logout(request): response.data = {"detail": message} response.status_code = status.HTTP_200_OK return response + + +class OAuthLoginService: + def __init__(self, request, provider: OAuth2Provider): + self.request = request + self.provider = provider + + def create_social_account(self, user: User, uid: str): + return SocialAccount.objects.create( + user=user, + provider=self.provider.name, + uid=uid, + ) + + @staticmethod + def get_user_avatar(url: str) -> ContentFile: + response = requests.get(url, stream=True) + response.raise_for_status() + return ContentFile(response.content, name='google_image.png') + + @transaction.atomic() + def get_or_create_user(self, user_data: GoogleUserInfo) -> User: + if user := User.objects.filter(Q(social_accounts__uid=user_data.id) & Q(email=user_data.email)).first(): + return user + if user := User.objects.filter(email=user_data.email).first(): + self.create_social_account(user, user_data.id) + return user + user = User.objects.create_user( + email=user_data.email, + password=None, + first_name=user_data.given_name, + last_name=user_data.family_name, + avatar=self.get_user_avatar(user_data.picture), + is_active=user_data.verified_email, + ) + self.create_social_account(user, user_data.id) + return user + + def login(self, code: str, state: str): + if not self.provider.validate_state(self.request, state): + raise OAuth2Error('Invalid state') + + response_data: GoogleTokenData = self.provider.get_access_token(code) + + user_data: GoogleUserInfo = self.provider.get_user_info(response_data.access_token) + + user = self.get_or_create_user(user_data) + login_service = LoginService(self.request) + return login_service.get_response(user) diff --git a/web/api/v1/auth_app/types.py b/web/api/v1/auth_app/types.py new file mode 100644 index 00000000..30ed9f5d --- /dev/null +++ b/web/api/v1/auth_app/types.py @@ -0,0 +1,26 @@ +from datetime import date +from typing import NamedTuple, TypedDict + +from main.models import GenderChoice + + +class PasswordResetConfirmData(NamedTuple): + uid: str + token: str + password_1: str + password_2: str + + +class CreateUserData(NamedTuple): + first_name: str + last_name: str + email: str + password_1: str + password_2: str + birthday: date = None + gender: GenderChoice = None + + +class PasswordResetDTO(NamedTuple): + uid: str + token: str diff --git a/web/api/v1/auth_app/urls.py b/web/api/v1/auth_app/urls.py index 228a14f9..aa3822ab 100644 --- a/web/api/v1/auth_app/urls.py +++ b/web/api/v1/auth_app/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import include, path from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView from . import views @@ -11,7 +11,9 @@ path('sign-up/verify/', views.VerifyEmailView.as_view(), name='sign-up-verify'), path('logout/', views.LogoutView.as_view(), name='logout'), path('password/reset/', views.PasswordResetView.as_view(), name='reset-password'), + path('password/reset/verify/', views.PasswordResetVerifyView.as_view(), name='reset-password-verify'), path('password/reset/confirm/', views.PasswordResetConfirmView.as_view(), name='reset-password-confirm'), path('token/refresh/', TokenRefreshView.as_view()), path('token/verify/', TokenVerifyView.as_view()), + path('', include('api.v1.auth_app.oauth.urls')), ] diff --git a/web/api/v1/auth_app/views.py b/web/api/v1/auth_app/views.py index afe693aa..fb4a9a58 100644 --- a/web/api/v1/auth_app/views.py +++ b/web/api/v1/auth_app/views.py @@ -6,7 +6,8 @@ from rest_framework.views import APIView from . import serializers -from .services import AuthAppService, LoginService, full_logout +from .managers import PasswordResetManager +from .services import AuthAppService, LoginService, PasswordResetHandler, SignUpHandler, full_logout class SignUpView(GenericAPIView): @@ -17,8 +18,8 @@ def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - service = AuthAppService() - service.create_user(serializer.validated_data) + service = SignUpHandler(serializer.validated_data) + service.create_user() return Response( {'detail': _('Confirmation email has been sent')}, status=status.HTTP_201_CREATED, @@ -54,14 +55,30 @@ def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - service = AuthAppService() - service.password_reset(serializer.data['email']) + service = PasswordResetHandler(serializer.data['email']) + service.reset_password() return Response( {'detail': _('Password reset e-mail has been sent.')}, status=status.HTTP_200_OK, ) +class PasswordResetVerifyView(GenericAPIView): + serializer_class = serializers.PasswordResetVerifySerializer + permission_classes = (AllowAny,) + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + handler = PasswordResetManager() + handler.validate( + token=serializer.validated_data['token'], + uid=serializer.validated_data['uid'], + raise_exception=True, + ) + return Response({'detail': True}, status=status.HTTP_200_OK) + + class PasswordResetConfirmView(GenericAPIView): serializer_class = serializers.PasswordResetConfirmSerializer permission_classes = (AllowAny,) @@ -69,8 +86,7 @@ class PasswordResetConfirmView(GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - service = AuthAppService() - service.password_reset_confirm(serializer.validated_data) + AuthAppService.password_reset_confirm(serializer.validated_data) return Response( {'detail': _('Password has been reset with the new password.')}, status=status.HTTP_200_OK, @@ -84,8 +100,7 @@ class VerifyEmailView(GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - service = AuthAppService() - service.verify_email_confirm(key=serializer.data['key']) + AuthAppService.verify_email_confirm(key=serializer.data['key']) return Response( {'detail': _('Email verified')}, status=status.HTTP_200_OK, diff --git a/web/api/v1/blog/serializers.py b/web/api/v1/blog/serializers.py index 48cb1d55..1cc495d7 100644 --- a/web/api/v1/blog/serializers.py +++ b/web/api/v1/blog/serializers.py @@ -3,7 +3,7 @@ from actions.choices import LikeIconStatus, LikeStatus from actions.serializers import LikeDislikeRelationSerializer -from api.v1.blog.services import BlogService, CommentQueryService +from api.v1.blog.services import CommentQueryService from blog.models import Article, ArticleTag, Category, Comment from user_profile.serializers import ShortUserSerializer @@ -60,7 +60,7 @@ class Meta(ArticleSerializer.Meta): class CreateArticleSerializer(serializers.ModelSerializer): # TaggitSerializer, - # tags = TagListSerializerField() + tags = TagListSerializerField() class Meta: model = Article @@ -69,17 +69,8 @@ class Meta: 'category', 'image', 'content', - ) # 'tags' - - def validate_title(self, title: str): - if BlogService.is_article_slug_exist(title): - raise serializers.ValidationError('This title already exists') - return title - - @transaction.atomic() - def create(self, validated_data: dict): - validated_data['author'] = self.context['request'].user - return super().create(validated_data) + 'tags', + ) class ParentCommentSerializer(serializers.ModelSerializer): diff --git a/web/api/v1/blog/services.py b/web/api/v1/blog/services.py index 04ce62f4..7e5be2f7 100644 --- a/web/api/v1/blog/services.py +++ b/web/api/v1/blog/services.py @@ -1,6 +1,9 @@ from django.db.models import Count, Prefetch, Q, QuerySet +from rest_framework.exceptions import ValidationError +from slugify import slugify from api.v1.actions.services import LikeQueryService +from api.v1.blog.types import CreateArticleT from blog.choices import ArticleStatus from blog.models import Article, ArticleTag, Category, Comment @@ -66,19 +69,11 @@ def popular_tags(self) -> QuerySet[dict]: return tags -class BlogService: +class CategoryQueryService: @staticmethod def category_queryset() -> QuerySet[Category]: return Category.objects.all() - def get_active_articles(self) -> QuerySet[Article]: - return ( - Article.objects.select_related('category', 'author') - .prefetch_related('tags') - .filter(status=ArticleStatus.ACTIVE) - .annotate(comments_count=Count('comment_set')) - ) - @staticmethod def get_comments_queryset() -> QuerySet[Comment]: return ( @@ -100,6 +95,19 @@ def get_article(self, article_id: int) -> Article: def get_comment(comment_id: int): return Comment.objects.get(id=comment_id) + +class CreateArticleService: + + @staticmethod + def _get_slug(value: str) -> str: + return slugify(value) + @staticmethod - def is_article_slug_exist(title: str) -> bool: - return Article.objects.filter(slug=Article.get_slug(title)).exists() + def is_article_slug_exist(slug: str) -> bool: + return Article.objects.filter(slug=slug).exists() + + def create_article(self, author: 'UserType', article_data: CreateArticleT) -> Article: + slug = self._get_slug(article_data['title']) + if self.is_article_slug_exist(slug): + raise ValidationError('Article with this title already exists') + return Article.objects.create(author=author, slug=slug, **article_data) diff --git a/web/api/v1/blog/types.py b/web/api/v1/blog/types.py new file mode 100644 index 00000000..c2ee090f --- /dev/null +++ b/web/api/v1/blog/types.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from django.core.files.uploadedfile import InMemoryUploadedFile + + from blog.models import Category + + +class CreateArticleT(TypedDict): + category: "Category" + title: str + content: str + image: "InMemoryUploadedFile" + tags: list[str] diff --git a/web/api/v1/blog/urls.py b/web/api/v1/blog/urls.py index f29359c8..28d899ee 100644 --- a/web/api/v1/blog/urls.py +++ b/web/api/v1/blog/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ + path('categories/', views.CategoryListView.as_view(), name='category-list'), path('articles/', views.ArticleListView.as_view(), name='post-list'), path('articles/new/', views.CreateArticleView.as_view(), name='new-post'), path('articles//', views.ArticleDetailView.as_view(), name='post-detail'), diff --git a/web/api/v1/blog/views.py b/web/api/v1/blog/views.py index d53840e9..2bfc79ef 100644 --- a/web/api/v1/blog/views.py +++ b/web/api/v1/blog/views.py @@ -1,11 +1,10 @@ from rest_framework import status from rest_framework.generics import CreateAPIView, GenericAPIView, ListAPIView -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from . import serializers from .filters import ArticleFilter -from .services import BlogQueryService, CommentQueryService, TagQueryService +from .services import BlogQueryService, CategoryQueryService, CommentQueryService, CreateArticleService, TagQueryService class ArticleListView(ListAPIView): @@ -35,8 +34,9 @@ class CreateArticleView(GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) + service = CreateArticleService() + article = service.create_article(request.user, serializer.validated_data) + return Response({'id': article.id}, status=status.HTTP_201_CREATED) class CommentListView(ListAPIView): @@ -59,3 +59,11 @@ class TagListView(ListAPIView): def get_queryset(self): return TagQueryService().popular_tags() + + +class CategoryListView(ListAPIView): + serializer_class = serializers.CategorySerializer + pagination_class = None + + def get_queryset(self): + return CategoryQueryService.category_queryset() diff --git a/web/api/v1/profile/serializers.py b/web/api/v1/profile/serializers.py index be0c2bda..c4d455fe 100644 --- a/web/api/v1/profile/serializers.py +++ b/web/api/v1/profile/serializers.py @@ -4,11 +4,14 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from auth_app.models import SocialAccount + User = get_user_model() class UserShortInfoSerializer(serializers.ModelSerializer): avatar = serializers.URLField(source='avatar_url') + # url = serializers.URLField(source='get_absolute_url') class Meta: @@ -26,7 +29,15 @@ class Meta: fields = ('id', 'first_name', 'last_name', 'birthday', 'gender') +class SocialAccountSerializer(serializers.ModelSerializer): + class Meta: + model = SocialAccount + fields = ('id', 'provider', 'connected') + + class UserSerializer(serializers.ModelSerializer): + social_accounts = SocialAccountSerializer(many=True) + class Meta: model = User fields = ( @@ -42,8 +53,9 @@ class Meta: 'followers_count', 'following_count', 'avatar', + 'social_accounts', ) - read_only_fields = ('full_name', 'user_likes', 'user_posts') + read_only_fields = ('full_name', 'user_likes', 'user_posts', 'social_accounts') class AvatarUpdateSerializer(serializers.ModelSerializer): diff --git a/web/api/v1/profile/services.py b/web/api/v1/profile/services.py index 446e05ab..e2c88455 100644 --- a/web/api/v1/profile/services.py +++ b/web/api/v1/profile/services.py @@ -32,7 +32,11 @@ def get_queryset(is_active: Optional[bool] = None) -> 'QuerySet[User]': def user_profile_queryset(self) -> 'QuerySet[User]': user_articles = Count('article_set', filter=Q(article_set__status=ArticleStatus.ACTIVE)) user_likes = Count('likes') - return self.get_queryset(is_active=True).annotate(user_posts=user_articles, user_likes=user_likes) + return ( + self.get_queryset(is_active=True) + .annotate(user_posts=user_articles, user_likes=user_likes) + .prefetch_related('social_accounts') + ) @staticmethod def exist_annotation(user) -> Exists: diff --git a/web/auth_app/migrations/0001_initial.py b/web/auth_app/migrations/0001_initial.py new file mode 100644 index 00000000..63a41fbe --- /dev/null +++ b/web/auth_app/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.9 on 2024-02-01 21:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SocialAccount', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.CharField(blank=True, max_length=40, null=True, unique=True)), + ('provider', models.CharField(choices=[('google', 'Google')], max_length=20)), + ('connected', models.DateTimeField(auto_now_add=True)), + ( + 'user', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='social_account', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'indexes': [models.Index(fields=['provider', 'uid'], name='auth_app_so_provide_03d647_idx')], + }, + ), + migrations.AddConstraint( + model_name='socialaccount', + constraint=models.UniqueConstraint(fields=('user', 'provider'), name='unique_user_provider'), + ), + migrations.AddConstraint( + model_name='socialaccount', + constraint=models.UniqueConstraint(fields=('provider', 'uid'), name='unique_uid_provider'), + ), + ] diff --git a/web/auth_app/migrations/0002_alter_socialaccount_provider_and_more.py b/web/auth_app/migrations/0002_alter_socialaccount_provider_and_more.py new file mode 100644 index 00000000..f63325ad --- /dev/null +++ b/web/auth_app/migrations/0002_alter_socialaccount_provider_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.7 on 2024-02-25 19:11 + +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.AUTH_USER_MODEL), + ('auth_app', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='socialaccount', + name='provider', + field=models.CharField(choices=[('google', 'Google'), ('facebook', 'Facebook')], max_length=20), + ), + migrations.AlterField( + model_name='socialaccount', + name='user', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/web/auth_app/migrations/__init__.py b/web/auth_app/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/auth_app/models.py b/web/auth_app/models.py new file mode 100644 index 00000000..1371ddce --- /dev/null +++ b/web/auth_app/models.py @@ -0,0 +1,22 @@ +from django.db import models + + +class SocialAccountProvider(models.TextChoices): + GOOGLE = 'google' + FACEBOOK = 'facebook' + + +class SocialAccount(models.Model): + user = models.ForeignKey('main.User', on_delete=models.CASCADE, related_name='social_accounts') + uid = models.CharField(max_length=40, blank=True, null=True, unique=True) + provider = models.CharField(max_length=20, choices=SocialAccountProvider.choices) + connected = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=('user', 'provider'), name='unique_user_provider'), + models.UniqueConstraint(fields=('provider', 'uid'), name='unique_uid_provider'), + ] + indexes = [ + models.Index(fields=('provider', 'uid')), + ] diff --git a/web/auth_app/static/auth_app/js/googleOauth.js b/web/auth_app/static/auth_app/js/googleOauth.js new file mode 100644 index 00000000..2e5144ea --- /dev/null +++ b/web/auth_app/static/auth_app/js/googleOauth.js @@ -0,0 +1,46 @@ +$(function () { + googleCallbackHandler(); + $('#loginGoogle').click(googleLoginInit); +}); + + +function googleLoginInit(e) { + $.ajax({ + url: '/api/v1/auth/oauth2/redirect-url/?provider=google', + method: 'get', + success: function (redirect_url) { + window.location.href = redirect_url; + } + }) +} + + +function googleCallbackHandler() { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code') + const scope = urlParams.get('scope') + const state = urlParams.get('state') + if (state && scope && state) { + const data = { + code: code, + scope: scope, + state: state, + } + $.ajax({ + url: '/api/v1/auth/google/sign-in/', + data: data, + method: 'post', + success: successGoogleCallback, + error: errorGoogleCallback, + }) + } +} + +function successGoogleCallback(data) { + console.log('successGoogleCallback', data) + window.location.href = '/profile/' +} + +function errorGoogleCallback(data) { + console.log('errorGoogleCallback', data) +} diff --git a/web/auth_app/static/auth_app/js/login.js b/web/auth_app/static/auth_app/js/login.js index 488d2e9d..20a60eb5 100644 --- a/web/auth_app/static/auth_app/js/login.js +++ b/web/auth_app/static/auth_app/js/login.js @@ -12,7 +12,6 @@ function login(e) { dataType: 'json', data: form.serialize(), success: function (data) { - localStorage.setItem('jwt', data.access_token) location.reload(); }, error: function (data) { diff --git a/web/auth_app/static/auth_app/js/resetConfirm.js b/web/auth_app/static/auth_app/js/resetConfirm.js index 6e0fd8f3..73d6a7ed 100644 --- a/web/auth_app/static/auth_app/js/resetConfirm.js +++ b/web/auth_app/static/auth_app/js/resetConfirm.js @@ -1,15 +1,14 @@ $(function () { + verifyToken(); $('#resetConfirmForm').submit(confirmReset); }); function confirmReset(e) { - let form = $(this); e.preventDefault(); const urlParams = new URLSearchParams(window.location.search); const data = { uid: urlParams.get('uid'), token: urlParams.get('token'), -// password_1: this.password_1.value, password_1: $("input[name=password_1]").val(), password_2: $("input[name=password_2]").val(), } @@ -54,3 +53,25 @@ function help_block(group, variable) { $(group).addClass(error_class_name); $(group).append('
' + variable + "
"); } + + +function verifyToken() { + const urlParams = new URLSearchParams(window.location.search); + const data = { + uid: urlParams.get('uid'), + token: urlParams.get('token'), + } + + $.ajax({ + url: "/api/v1/auth/password/reset/verify/", + type: "POST", + data: data, + success: function (data) { + + }, + error: function (data) { + $('#resetConfirm').empty() + $('#resetConfirm').append('

Link invalid or expired

') + }, + }) +} diff --git a/web/auth_app/static/auth_app/js/sign-up.js b/web/auth_app/static/auth_app/js/sign-up.js index 821b1fe6..f52fca72 100644 --- a/web/auth_app/static/auth_app/js/sign-up.js +++ b/web/auth_app/static/auth_app/js/sign-up.js @@ -17,7 +17,7 @@ function passwordVisibility() { } function singUp(e) { - let form = $(this); + const form = $(this); e.preventDefault(); $.ajax({ url: form.attr("action"), @@ -25,7 +25,7 @@ function singUp(e) { dataType: 'json', data: form.serialize(), success: function (data) { -// window.location.href = form.data('href'); + window.location.href = form.data('href'); }, error: function (data) { error_process(data); diff --git a/web/auth_app/templates/auth_app/googleRedirect.html b/web/auth_app/templates/auth_app/googleRedirect.html new file mode 100644 index 00000000..e7ec3799 --- /dev/null +++ b/web/auth_app/templates/auth_app/googleRedirect.html @@ -0,0 +1,7 @@ +{% extends 'auth_app/base.html'%} +{% load static %} + + +{% block jquery %} +$.getScript('{% static 'auth_app/js/googleOauth.js' %}'); +{% endblock %} diff --git a/web/auth_app/templates/auth_app/login.html b/web/auth_app/templates/auth_app/login.html index 56afa489..5cb86983 100644 --- a/web/auth_app/templates/auth_app/login.html +++ b/web/auth_app/templates/auth_app/login.html @@ -2,6 +2,7 @@ {% load static %} {% block head %} + {% endblock head %} @@ -35,6 +36,9 @@

Please Sign In

Register +
+ +
@@ -45,4 +49,5 @@

Please Sign In

{% block jquery %} $.getScript('{% static 'auth_app/js/login.js' %}'); +$.getScript('{% static 'auth_app/js/googleOauth.js' %}'); {% endblock %} diff --git a/web/auth_app/templates/auth_app/reset_password_confirm.html b/web/auth_app/templates/auth_app/reset_password_confirm.html index 02f81d72..035f8fe5 100644 --- a/web/auth_app/templates/auth_app/reset_password_confirm.html +++ b/web/auth_app/templates/auth_app/reset_password_confirm.html @@ -4,7 +4,7 @@ {% block container %}
-
+
{% csrf_token %}
diff --git a/web/auth_app/templates/auth_app/sign_up.html b/web/auth_app/templates/auth_app/sign_up.html index 21654693..85c63b78 100644 --- a/web/auth_app/templates/auth_app/sign_up.html +++ b/web/auth_app/templates/auth_app/sign_up.html @@ -10,15 +10,16 @@
- + Sign Up

It's free and always will be.

-
+ +
-
+ +
diff --git a/web/auth_app/urls.py b/web/auth_app/urls.py index 88eecd10..4246569b 100644 --- a/web/auth_app/urls.py +++ b/web/auth_app/urls.py @@ -22,4 +22,5 @@ TemplateAPIView.as_view(template_name='auth_app/verification_sent.html'), name='verify_email_sent', ), + path('auth/google', TemplateAPIView.as_view(template_name='auth_app/googleRedirect.html'), name='google_auth'), ] diff --git a/web/blog/models.py b/web/blog/models.py index a0aba3c9..fc84c71a 100644 --- a/web/blog/models.py +++ b/web/blog/models.py @@ -67,14 +67,6 @@ def short_title(self) -> str: def __str__(self) -> str: return '{title} - {author}'.format(title=self.short_title, author=self.author) - @staticmethod - def get_slug(title: str) -> str: - return slugify(title, allow_unicode=True) - - def save(self, **kwargs): - self.slug = self.get_slug(self.title) - return super().save(**kwargs) - def get_absolute_url(self) -> str: return reverse_lazy('blog:blog-detail', kwargs={'slug': self.slug}) diff --git a/web/blog/static/blog/js/post_create.js b/web/blog/static/blog/js/post_create.js index e1bb5082..d6ed3b6a 100644 --- a/web/blog/static/blog/js/post_create.js +++ b/web/blog/static/blog/js/post_create.js @@ -1,10 +1,24 @@ $(function () { + $('.category-select').select2(); $('#createArticleForm').submit(postCreate); - + getCategories(); }); const error_class_name = "has-error" +function getCategories() { + $.ajax({ + url: '/api/v1/blog/categories/', + type: 'GET', + success: getCategoriesHandler, + }) +} + +function getCategoriesHandler (data) { + const selector = $('.category-select') + data.forEach((category) => selector.append(new Option(category.name, category.id))) +} + function postCreate(event) { event.preventDefault() let form = $(this) diff --git a/web/blog/templates/blog/post_create.html b/web/blog/templates/blog/post_create.html index 8b60be05..6214cdf9 100644 --- a/web/blog/templates/blog/post_create.html +++ b/web/blog/templates/blog/post_create.html @@ -1,26 +1,27 @@ {% extends "blog/base.html" %} -{% load static main %} +{% load static %} {% block title %}New Post{% endblock title %} {% block head %} - + + {% endblock head %} {% block container %}

Create a new post

- +
- +
+
@@ -50,6 +51,7 @@

Create a new post

{% block script %} +