From 5206f95e3f6848c3de789030eefe858b853c4433 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 21 Oct 2021 09:05:08 +0200 Subject: [PATCH 1/6] Latest changes --- djeveric/__init__.py | 154 ------------------------------------------- requirements.txt | 3 +- userausfall/ldap.py | 37 +++++++++-- 3 files changed, 33 insertions(+), 161 deletions(-) delete mode 100644 djeveric/__init__.py diff --git a/djeveric/__init__.py b/djeveric/__init__.py deleted file mode 100644 index 5c0c9d3..0000000 --- a/djeveric/__init__.py +++ /dev/null @@ -1,154 +0,0 @@ -from django.contrib.auth import get_user_model -from django.contrib.auth.tokens import default_token_generator -from django.contrib.contenttypes.models import ContentType -from django.utils.encoding import force_bytes -from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode -from rest_framework import serializers, status -from rest_framework.exceptions import ValidationError -from rest_framework.response import Response -from rest_framework.settings import api_settings -from rest_framework.views import APIView - - -def encode_pk(resource): - """Encode the primary key of a resource with Base64 for usage in URLs.""" - return urlsafe_base64_encode(force_bytes(resource.pk)) - - -class ConfirmationError(Exception): - """An error occurring while checking a confirmation.""" - - def __init__(self, message=None, errors=None): - if errors is None: - self.errors = {} - else: - self.errors = errors - if message is not None: - non_field_errors = self.errors.get(api_settings.NON_FIELD_ERRORS_KEY, []) - non_field_errors.append(message) - self.errors[api_settings.NON_FIELD_ERRORS_KEY] = non_field_errors - - -class Email: - """ - Base class for an email message. - """ - subject = "" - - def __init__(self, user): - self.user = user - - def get_subject(self): - return self.subject - - def get_message(self, context): - raise NotImplementedError() - - def send(self, context): - self.user.email_user(self.get_subject(), self.get_message(context)) - - -class Confirmation: - """ - Base class for handling a confirmation process. - """ - email_class = Email - - def __init__(self, user, resource): - self.user = user - self.resource = resource - - def check(self): - if not self.has_permission(self.user, self.resource): - raise ConfirmationError("Permission denied") - if self.is_confirmed(self.resource): - raise ConfirmationError("Already confirmed") - self.confirm(self.resource) - - def confirm(self, resource): - """Overwrite this method to supply operations to confirm the resource.""" - pass - - def get_email(self): - return self.email_class(self.user) - - def has_permission(self, user, resource) -> bool: - """Overwrite this method returning if a user may confirm a resource.""" - return False - - def is_confirmed(self, resource) -> bool: - """Overwrite this method to tell if a resource is confirmed.""" - return False - - def send_request(self): - if self.is_confirmed(self.resource): - return - self.get_email().send({ - "token": default_token_generator.make_token(self.user), - "uid": encode_pk(self.user), - "rtid": encode_pk(ContentType.objects.get_for_model(self.resource)), - "rid": encode_pk(self.resource), - }) - - -class ConfirmationSerializer(serializers.Serializer): - """ - Serializer class for confirmation requests. - """ - token = serializers.CharField() - uid = serializers.CharField() - rtid = serializers.CharField() - rid = serializers.CharField() - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.user = None - self.resource_type = None - self.resource = None - - def validate_uid(self, value): - self.user = self.get_model_object(get_user_model(), value) - return value - - def validate_rtid(self, value): - self.resource_type = self.get_model_object(ContentType, value) - return value - - def validate(self, data): - # we need to be sure that the rtid was already decoded - self.resource = self.get_model_object(self.resource_type.model_class(), data["rid"]) - return data - - def get_model_object(self, model, oid): - try: - # urlsafe_base64_decode() decodes to bytestring - oid = urlsafe_base64_decode(oid).decode() - return model._default_manager.get(id=oid) - except (TypeError, ValueError, OverflowError, model.DoesNotExist): - raise ValidationError("Error while decoding object id") - - -class ConfirmationView(APIView): - """ - View for creating a confirmation API endpoint. - """ - confirmation_class = Confirmation - serializer_class = ConfirmationSerializer - - def post(self, request, format=None): - try: - self.check_confirmation(request.data) - return Response({}, status=status.HTTP_204_NO_CONTENT) - except ConfirmationError as e: - return Response(e.errors, status=status.HTTP_400_BAD_REQUEST) - - def check_confirmation(self, data): - serializer = self.serializer_class(data=data) - if not serializer.is_valid(): - raise ConfirmationError(errors=serializer.errors) - token = serializer.validated_data["token"] - if default_token_generator.check_token(serializer.user, token): - # confirm resource or raise an exception otherwise - self.confirmation_class(serializer.user, serializer.resource).check() - else: - raise ConfirmationError("Invalid token") diff --git a/requirements.txt b/requirements.txt index 906b98f..8d9f4b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ setuptools~=40.8.0 django~=2.2.13 -djangorestframework~=3.9.0 \ No newline at end of file +djangorestframework~=3.9.0 +ldap3~=2.4.1 \ No newline at end of file diff --git a/userausfall/ldap.py b/userausfall/ldap.py index e1ff065..8931665 100644 --- a/userausfall/ldap.py +++ b/userausfall/ldap.py @@ -3,6 +3,36 @@ from ldap3 import Server, Connection, SYNC def create_account(username, raw_password): + connection = _get_connection() + is_success = connection.add( + f"cn={username},dc=local", + ["simpleSecurityObject", "organizationalRole"], + {"userPassword": raw_password}, + ) + return is_success + + +def account_exists(username): + connection = _get_connection() + exists = connection.search( + f"cn={username},dc=local", "(objectclass=simpleSecurityObject)" + ) + return exists + + +def is_valid_account_data(username, raw_password): + connection = _get_connection() + is_valid = connection.search( + f"cn={username},dc=local", + "(objectclass=simpleSecurityObject)", + attributes=["userPassword"], + ) + if is_valid: + is_valid = connection.entries[0]["userPassword"].value == raw_password + return is_valid + + +def _get_connection(): server = Server("localhost") # The SAFE_SYNC client strategy doesn't seem to be present in Buster version of ldap3. We might want to use it as # soon as it is available (multithreading). @@ -13,9 +43,4 @@ def create_account(username, raw_password): client_strategy=SYNC, auto_bind=True, ) - is_success = connection.add( - f"cn={username},dc=local", - ["simpleSecurityObject", "organizationalRole"], - {"userPassword": raw_password}, - ) - return is_success + return connection From 96f4a81a3b7b52b9049ee1e4ad2bd503250155f0 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 21 Oct 2021 09:43:01 +0200 Subject: [PATCH 2/6] chore: Adapt project env to memoorje --- .editorconfig | 17 +++++++++++++++++ .gitignore | 46 +++++++++++++++++++--------------------------- pyproject.toml | 13 +++++++++++++ requirements.txt | 4 ---- setup.cfg | 7 +++++++ setup.py | 44 +++++++++++++++++++++++++++++++++++--------- tox.ini | 25 +++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 40 deletions(-) create mode 100644 .editorconfig create mode 100644 pyproject.toml delete mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 tox.ini diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bb5e2ed --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.py] +indent_size = 4 + +[{Makefile,debian/rules,make.d/*}] +indent_style = tab +indent_size = 4 diff --git a/.gitignore b/.gitignore index aaf09c5..d0b2d8d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,27 @@ -# local env files -.env -.env.local -.env.*.local - -# Log files -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Editor directories and files -.idea -.vscode -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -.DS_Store -node_modules -/dist __pycache__/ + +/data/* +!/data/.gitkeep /db.sqlite3 -/venv/ +/media/ + +/dist/ +*.egg-info/ +/build/ +/.pybuild/ +/.tox/ +.coverage* /debian/*debhelper* /debian/*.substvars /debian/files /debian/python3-userausfall/ /debian/userausfall/ /debian/userausfall-webapp/ -/.pybuild/ -/build/ -/userausfall.egg-info/ + +node_modules/ +app/dist/ +npm-debug.log* + +.idea/ +*.env* +*.swp diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9b7a2f3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[tool.black] +line-length = 120 +target-version = ['py39'] +exclude = ''' +( + /( + \.eggs + | \.git + | \.tox + | migrations + ) +) +''' diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8d9f4b2..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -setuptools~=40.8.0 -django~=2.2.13 -djangorestframework~=3.9.0 -ldap3~=2.4.1 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..ac4ff4e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[flake8] +max-line-length = 120 +select = C,E,F,I,W,B,B950 +ignore = E203, E501, W503 +exclude = .tox, node_modules, src, **/migrations/*.py +import-order-style = google +application-import-names = userausfall diff --git a/setup.py b/setup.py index 0a4e9fc..17d5754 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,45 @@ -from setuptools import setup, find_packages +import os + +from setuptools import find_namespace_packages, setup, find_packages from userausfall import __version__ +__dir__ = os.path.abspath(os.path.dirname(__file__)) + +try: + with open(os.path.join(__dir__, "README.md")) as f: + long_description = "\n" + f.read() +except FileNotFoundError: + long_description = "" + setup( name="userausfall", version=__version__, description="account management for systemausfall.org", + long_description=long_description, + long_description_content_type="text/markdown", url="https://git.systemausfall.org/systemausfall.org/userausfall", - author="Robert Waltemath", - author_email="rw@roko.li", - packages=find_packages(), - install_requires=( - "django>=2.2<3.0", - # "djangorestframework>=3.12<4.0", - # "djoser>=2.1<3.0", - ), + author="userausfall developers", + author_email="hallo@roko.li", + license="AGPL-3.0-or-later", + packages=find_namespace_packages(include=["userausfall"]), + install_requires=[ + "django~=3.2.8", + "djangorestframework~=3.12.1", + "djangorestframework-camel-case~=1.2.0", + "django-filter~=2.4.0", + "django-rest-registration~=0.6.4", + "djeveric@https://git.hack-hro.de/memoorje/djeveric/-/archive/main/djeveric-main.tar.gz", + "drf-spectacular~=0.18.2", + "ldap3~=2.8.1", + ], include_package_data=True, + classifiers=[ + "Development Status :: 3 - Alpha", + "Framework :: Django :: 3.2", + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Internet :: WWW/HTTP", + ], ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..da5e852 --- /dev/null +++ b/tox.ini @@ -0,0 +1,25 @@ +[tox] +envlist = lint, test-py3-app +skip_missing_interpreters = true +minversion = 3.21.0 + +[testenv:lint] +# no need to install package with deps to lint sources +skip_install = true +deps = + black + flake8 + flake8-import-order +setenv = + FORMAT_PATHS = userausfall{/} setup.py libpy{/} +commands = + python3 -m flake8 {env:FORMAT_PATHS} + python3 -m black --check {env:FORMAT_PATHS} + +[testenv:test-py3-app] +sitepackages = true +deps = + coverage +setenv = DJANGO_SETTINGS_MODULE=userausfall.settings +commands = + python3 -m coverage run --append --source='userausfall' -m django test --verbosity=2 From 79b7bc8364c2fe523a2d9ba5a32f32f5e3926568 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 21 Oct 2021 09:58:18 +0200 Subject: [PATCH 3/6] chore: Reformat files --- setup.py | 2 +- tox.ini | 2 +- userausfall/__init__.py | 2 - userausfall/admin.py | 2 +- userausfall/apps.py | 8 --- userausfall/confirmations.py | 1 + userausfall/ldap.py | 6 +- userausfall/models.py | 8 +-- userausfall/rest_api/serializers.py | 2 +- userausfall/rest_api/urls.py | 3 +- userausfall/rest_api/views.py | 4 +- userausfall/settings.py | 92 ++++++++++++++--------------- userausfall/signals.py | 11 ---- userausfall/urls.py | 8 +-- userausfall/wsgi.py | 6 +- 15 files changed, 63 insertions(+), 94 deletions(-) delete mode 100644 userausfall/apps.py delete mode 100644 userausfall/signals.py diff --git a/setup.py b/setup.py index 17d5754..2929038 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import os -from setuptools import find_namespace_packages, setup, find_packages +from setuptools import find_namespace_packages, setup from userausfall import __version__ diff --git a/tox.ini b/tox.ini index da5e852..b0582a8 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ deps = flake8 flake8-import-order setenv = - FORMAT_PATHS = userausfall{/} setup.py libpy{/} + FORMAT_PATHS = userausfall{/} setup.py commands = python3 -m flake8 {env:FORMAT_PATHS} python3 -m black --check {env:FORMAT_PATHS} diff --git a/userausfall/__init__.py b/userausfall/__init__.py index 8257966..3dc1f76 100644 --- a/userausfall/__init__.py +++ b/userausfall/__init__.py @@ -1,3 +1 @@ __version__ = "0.1.0" - -default_app_config = 'userausfall.apps.UserausfallConfig' diff --git a/userausfall/admin.py b/userausfall/admin.py index 47505e8..43eab90 100644 --- a/userausfall/admin.py +++ b/userausfall/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from userausfall.models import User, TrustBridge +from userausfall.models import TrustBridge, User admin.site.register(TrustBridge) admin.site.register(User) diff --git a/userausfall/apps.py b/userausfall/apps.py deleted file mode 100644 index 5c928df..0000000 --- a/userausfall/apps.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.apps import AppConfig - - -class UserausfallConfig(AppConfig): - name = 'userausfall' - - def ready(self): - from . import signals diff --git a/userausfall/confirmations.py b/userausfall/confirmations.py index 33614e0..53cb480 100644 --- a/userausfall/confirmations.py +++ b/userausfall/confirmations.py @@ -1,4 +1,5 @@ from djeveric import Confirmation + from userausfall.emails import ConfidantConfirmationEmail from userausfall.models import User diff --git a/userausfall/ldap.py b/userausfall/ldap.py index 8931665..7e2e0b9 100644 --- a/userausfall/ldap.py +++ b/userausfall/ldap.py @@ -1,5 +1,5 @@ from django.conf import settings -from ldap3 import Server, Connection, SYNC +from ldap3 import Connection, Server, SYNC def create_account(username, raw_password): @@ -14,9 +14,7 @@ def create_account(username, raw_password): def account_exists(username): connection = _get_connection() - exists = connection.search( - f"cn={username},dc=local", "(objectclass=simpleSecurityObject)" - ) + exists = connection.search(f"cn={username},dc=local", "(objectclass=simpleSecurityObject)") return exists diff --git a/userausfall/models.py b/userausfall/models.py index 158e8ba..59587ca 100644 --- a/userausfall/models.py +++ b/userausfall/models.py @@ -58,9 +58,7 @@ class User(AbstractBaseUser, PermissionsMixin): username = models.CharField( _("username"), max_length=150, - help_text=_( - "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." - ), + help_text=_("Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."), validators=[username_validator], error_messages={"unique": _("A user with that username already exists.")}, unique=True, @@ -96,9 +94,7 @@ class User(AbstractBaseUser, PermissionsMixin): if not self.username: raise MissingUserAttribute("User is missing a username.") if not self.check_password(raw_password): - raise PasswordMismatch( - "The given password does not match the user's password." - ) + raise PasswordMismatch("The given password does not match the user's password.") return ldap.create_account(self.username, raw_password) diff --git a/userausfall/rest_api/serializers.py b/userausfall/rest_api/serializers.py index 4127b16..a9a6479 100644 --- a/userausfall/rest_api/serializers.py +++ b/userausfall/rest_api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from userausfall.models import User, TrustBridge +from userausfall.models import TrustBridge, User class TrustBridgeSerializer(serializers.ModelSerializer): diff --git a/userausfall/rest_api/urls.py b/userausfall/rest_api/urls.py index 76ba0ab..c3a2ff6 100644 --- a/userausfall/rest_api/urls.py +++ b/userausfall/rest_api/urls.py @@ -1,10 +1,9 @@ -from django.urls import path from rest_framework import routers from userausfall.rest_api.views import UserViewSet router = routers.DefaultRouter(trailing_slash=True) -router.register(r'users', UserViewSet, basename="user") +router.register(r"users", UserViewSet, basename="user") urlpatterns = [ # path("confirm/confidant/", ConfidantConfirmationView.as_view()) diff --git a/userausfall/rest_api/views.py b/userausfall/rest_api/views.py index 577cbe8..0f3f4c0 100644 --- a/userausfall/rest_api/views.py +++ b/userausfall/rest_api/views.py @@ -1,8 +1,8 @@ -from rest_framework import viewsets, status +from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.response import Response -from userausfall.models import User, MissingUserAttribute, PasswordMismatch +from userausfall.models import MissingUserAttribute, PasswordMismatch, User from userausfall.rest_api.permissions import UserPermission from userausfall.rest_api.serializers import ( ActivateUserSerializer, diff --git a/userausfall/settings.py b/userausfall/settings.py index 0c053fa..c96f589 100644 --- a/userausfall/settings.py +++ b/userausfall/settings.py @@ -19,7 +19,7 @@ DATA_DIR = os.environ.get("USERAUSFALL_DATA_DIR", BASE_DIR) # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get('LOCAL_DJANGO_SECRET_KEY') +SECRET_KEY = os.environ.get("LOCAL_DJANGO_SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -30,55 +30,55 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.sites', - 'django.contrib.staticfiles', - 'userausfall', - 'rest_framework', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.sites", + "django.contrib.staticfiles", + "userausfall", + "rest_framework", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'userausfall.urls' +ROOT_URLCONF = "userausfall.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'userausfall.wsgi.application' +WSGI_APPLICATION = "userausfall.wsgi.application" # Database # https://docs.djangoproject.com/en/2.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(DATA_DIR, 'db.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(DATA_DIR, "db.sqlite3"), } } @@ -86,7 +86,7 @@ DATABASES = { # User model # -AUTH_USER_MODEL = 'userausfall.User' +AUTH_USER_MODEL = "userausfall.User" # Password validation @@ -94,16 +94,16 @@ AUTH_USER_MODEL = 'userausfall.User' AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -111,9 +111,9 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ -LANGUAGE_CODE = 'de-de' +LANGUAGE_CODE = "de-de" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -125,30 +125,28 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.2/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" # Media files # https://docs.djangoproject.com/en/2.2/topics/files/ -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +MEDIA_ROOT = os.path.join(BASE_DIR, "media") -MEDIA_URL = '/media/' +MEDIA_URL = "/media/" # Sending email # https://docs.djangoproject.com/en/3.2/topics/email/ -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # Django Rest Framework # https://www.django-rest-framework.org/api-guide/settings/ REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.SessionAuthentication', - ), + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), } @@ -159,6 +157,6 @@ SITE_ID = 1 USERAUSFALL_LDAP = { - 'ADMIN_USER_DN': 'cn=admin,dc=local', - 'ADMIN_USER_PASSWORD': os.environ.get('USERAUSFALL_LDAP_PASSWORD'), + "ADMIN_USER_DN": "cn=admin,dc=local", + "ADMIN_USER_PASSWORD": os.environ.get("USERAUSFALL_LDAP_PASSWORD"), } diff --git a/userausfall/signals.py b/userausfall/signals.py deleted file mode 100644 index d3f1789..0000000 --- a/userausfall/signals.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.db.models.signals import post_save -from django.dispatch import receiver - -from userausfall.models import User - - -@receiver(post_save, sender=User) -def user_saved(sender, instance: User, **kwargs): - # if instance.confidant_unconfirmed is not None: - # ConfidantConfirmation(instance.confidant_unconfirmed, instance).send_request() - pass diff --git a/userausfall/urls.py b/userausfall/urls.py index 64efbbd..252cfbf 100644 --- a/userausfall/urls.py +++ b/userausfall/urls.py @@ -1,10 +1,8 @@ from django.contrib import admin -from django.urls import path, include - -from userausfall.rest_api import urls as rest_api_urls +from django.urls import include, path urlpatterns = [ - path('admin/', admin.site.urls), - path('api/', include("userausfall.rest_api.urls")), + path("admin/", admin.site.urls), + path("api/", include("userausfall.rest_api.urls")), path("api-auth/", include("rest_framework.urls")), ] diff --git a/userausfall/wsgi.py b/userausfall/wsgi.py index 2d6fc6f..d128640 100644 --- a/userausfall/wsgi.py +++ b/userausfall/wsgi.py @@ -1,16 +1,16 @@ """ -WSGI config for userausfall project. +WSGI config for memoorje project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'userausfall.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "userausfall.settings") application = get_wsgi_application() From ceb032dddb264646e3389e9750839efe940e9e7c Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 21 Oct 2021 10:04:15 +0200 Subject: [PATCH 4/6] fix: Make tox tests run --- userausfall/settings.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/userausfall/settings.py b/userausfall/settings.py index c96f589..eeed203 100644 --- a/userausfall/settings.py +++ b/userausfall/settings.py @@ -2,24 +2,25 @@ Django settings for userausfall. For more information on this file, see -https://docs.djangoproject.com/en/2.2/topics/settings/ +https://docs.djangoproject.com/en/3.2/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.2/ref/settings/ +https://docs.djangoproject.com/en/3.2/ref/settings/ """ import os +from pathlib import Path -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -DATA_DIR = os.environ.get("USERAUSFALL_DATA_DIR", BASE_DIR) +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = Path(os.environ.get("USERAUSFALL_DATA_DIR", BASE_DIR)) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get("LOCAL_DJANGO_SECRET_KEY") +SECRET_KEY = "django-insecure-ngy2599=i5c*5(*bw%gbs&jzb(^p-4zk&6!a8a76tevv$tb9xq" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -83,6 +84,12 @@ DATABASES = { } +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + # User model # From a887be1368bcc1dede4250e3083c9dbea996bd39 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 21 Oct 2021 10:29:37 +0200 Subject: [PATCH 5/6] refactor: Adapt python and settings to memoorje --- userausfall/confirmations.py | 18 -------- userausfall/emails.py | 4 +- userausfall/models.py | 2 +- userausfall/rest_api/permissions.py | 19 --------- userausfall/rest_api/urls.py | 15 +++---- userausfall/rest_api/views.py | 25 +---------- userausfall/settings.py | 65 ++++++++++++++++++++++------- userausfall/urls.py | 1 - 8 files changed, 61 insertions(+), 88 deletions(-) delete mode 100644 userausfall/confirmations.py delete mode 100644 userausfall/rest_api/permissions.py diff --git a/userausfall/confirmations.py b/userausfall/confirmations.py deleted file mode 100644 index 53cb480..0000000 --- a/userausfall/confirmations.py +++ /dev/null @@ -1,18 +0,0 @@ -from djeveric import Confirmation - -from userausfall.emails import ConfidantConfirmationEmail -from userausfall.models import User - - -class ConfidantConfirmation(Confirmation): - email_class = ConfidantConfirmationEmail - - def has_permission(self, user: User, resource: User): - return user == resource.confidant_unconfirmed - - def is_confirmed(self, resource: User): - return resource.confidant_unconfirmed == resource.confidant - - def confirm(self, resource: User): - resource.confidant = resource.confidant_unconfirmed - resource.save() diff --git a/userausfall/emails.py b/userausfall/emails.py index 2d4d632..45efdd2 100644 --- a/userausfall/emails.py +++ b/userausfall/emails.py @@ -1,7 +1,7 @@ -from djeveric import Email +from djeveric.emails import ConfirmationEmail -class ConfidantConfirmationEmail(Email): +class ConfidantConfirmationEmail(ConfirmationEmail): subject = "TODO" def get_message(self, context): diff --git a/userausfall/models.py b/userausfall/models.py index 59587ca..7331b19 100644 --- a/userausfall/models.py +++ b/userausfall/models.py @@ -52,7 +52,7 @@ class UserManager(BaseUserManager): return self._create_user(email, password, **extra_fields) -class User(AbstractBaseUser, PermissionsMixin): +class User(PermissionsMixin, AbstractBaseUser): username_validator = UnicodeUsernameValidator() username = models.CharField( diff --git a/userausfall/rest_api/permissions.py b/userausfall/rest_api/permissions.py deleted file mode 100644 index 0291279..0000000 --- a/userausfall/rest_api/permissions.py +++ /dev/null @@ -1,19 +0,0 @@ -from rest_framework import permissions - - -class UserPermission(permissions.BasePermission): - def has_permission(self, request, view): - result = False - if view.action == "activate": - result = True - elif view.action == "create": - result = True - elif view.action == "retrieve_authenticated": - result = request.user.is_authenticated - return result - - def has_object_permission(self, request, view, obj): - result = False - if view.action == "activate": - result = request.user == obj - return result diff --git a/userausfall/rest_api/urls.py b/userausfall/rest_api/urls.py index c3a2ff6..a3a4ed8 100644 --- a/userausfall/rest_api/urls.py +++ b/userausfall/rest_api/urls.py @@ -1,12 +1,13 @@ +from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from rest_framework import routers -from userausfall.rest_api.views import UserViewSet - -router = routers.DefaultRouter(trailing_slash=True) -router.register(r"users", UserViewSet, basename="user") +router = routers.SimpleRouter() urlpatterns = [ - # path("confirm/confidant/", ConfidantConfirmationView.as_view()) + path("", include(router.urls)), + path("auth/", include("rest_registration.api.urls")), + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path("schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] - -urlpatterns += router.urls diff --git a/userausfall/rest_api/views.py b/userausfall/rest_api/views.py index 0f3f4c0..b64d9eb 100644 --- a/userausfall/rest_api/views.py +++ b/userausfall/rest_api/views.py @@ -3,24 +3,9 @@ from rest_framework.decorators import action from rest_framework.response import Response from userausfall.models import MissingUserAttribute, PasswordMismatch, User -from userausfall.rest_api.permissions import UserPermission -from userausfall.rest_api.serializers import ( - ActivateUserSerializer, - CreateUserSerializer, - RetrieveUserSerializer, -) -class UserViewSet(viewsets.ModelViewSet): - permission_classes = [UserPermission] - queryset = User.objects.all() - - @action(detail=False, url_path="me") - def retrieve_authenticated(self, request): - """Retrieve user data for logged in user.""" - serializer = self.get_serializer(request.user) - return Response(serializer.data) - +class UserViewSet(viewsets.GenericViewSet): @action(detail=True, methods=["post"]) def activate(self, request, pk=None): """Create the corresponding LDAP account.""" @@ -38,11 +23,3 @@ class UserViewSet(viewsets.ModelViewSet): return Response(status=status.HTTP_204_NO_CONTENT) else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.action == "activate": - return ActivateUserSerializer - elif self.action == "create": - return CreateUserSerializer - elif self.action == "retrieve_authenticated": - return RetrieveUserSerializer diff --git a/userausfall/settings.py b/userausfall/settings.py index eeed203..5fb82af 100644 --- a/userausfall/settings.py +++ b/userausfall/settings.py @@ -38,8 +38,12 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.sites", "django.contrib.staticfiles", - "userausfall", "rest_framework", + "rest_registration", + "django_filters", + "drf_spectacular", + "userausfall", + "userausfall.rest_api", ] MIDDLEWARE = [ @@ -79,23 +83,11 @@ WSGI_APPLICATION = "userausfall.wsgi.application" DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(DATA_DIR, "db.sqlite3"), + "NAME": DATA_DIR / "db.sqlite3", } } -# Default primary key field type -# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - - -# User model -# - -AUTH_USER_MODEL = "userausfall.User" - - # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators @@ -138,11 +130,23 @@ STATIC_URL = "/static/" # Media files # https://docs.djangoproject.com/en/2.2/topics/files/ -MEDIA_ROOT = os.path.join(BASE_DIR, "media") +MEDIA_ROOT = DATA_DIR / "media" MEDIA_URL = "/media/" +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +# User model +# + +AUTH_USER_MODEL = "userausfall.User" + + # Sending email # https://docs.djangoproject.com/en/3.2/topics/email/ @@ -153,7 +157,36 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # https://www.django-rest-framework.org/api-guide/settings/ REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend", + ], + "DEFAULT_PARSER_CLASSES": [ + "djangorestframework_camel_case.parser.CamelCaseFormParser", + "djangorestframework_camel_case.parser.CamelCaseMultiPartParser", + "djangorestframework_camel_case.parser.CamelCaseJSONParser", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_RENDERER_CLASSES": [ + "djangorestframework_camel_case.render.CamelCaseJSONRenderer", + "djangorestframework_camel_case.render.CamelCaseBrowsableAPIRenderer", + ], + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "TEST_REQUEST_DEFAULT_FORMAT": "json", +} + +REST_REGISTRATION = { + "REGISTER_VERIFICATION_ENABLED": False, + "REGISTER_EMAIL_VERIFICATION_ENABLED": False, + "RESET_PASSWORD_VERIFICATION_ENABLED": False, +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Userausfall API", + "DESCRIPTION": "Account management for systemausfall.org", + "VERSION": "0.0.1", + "POSTPROCESSING_HOOKS": ["drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields"], } diff --git a/userausfall/urls.py b/userausfall/urls.py index 252cfbf..575221f 100644 --- a/userausfall/urls.py +++ b/userausfall/urls.py @@ -4,5 +4,4 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("api/", include("userausfall.rest_api.urls")), - path("api-auth/", include("rest_framework.urls")), ] From b4c686bfaafe3283ad6c6e7b46d04c0970c3a3d2 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 21 Oct 2021 10:46:26 +0200 Subject: [PATCH 6/6] test: Add auth tests --- userausfall/rest_api/tests/__init__.py | 1 + userausfall/rest_api/tests/auth.py | 68 +++++++++++++++++++++++ userausfall/rest_api/tests/userausfall.py | 13 +++++ userausfall/tests.py | 23 ++++++++ 4 files changed, 105 insertions(+) create mode 100644 userausfall/rest_api/tests/__init__.py create mode 100644 userausfall/rest_api/tests/auth.py create mode 100644 userausfall/rest_api/tests/userausfall.py create mode 100644 userausfall/tests.py diff --git a/userausfall/rest_api/tests/__init__.py b/userausfall/rest_api/tests/__init__.py new file mode 100644 index 0000000..78ced3d --- /dev/null +++ b/userausfall/rest_api/tests/__init__.py @@ -0,0 +1 @@ +from .auth import * # noqa: F401, F403 diff --git a/userausfall/rest_api/tests/auth.py b/userausfall/rest_api/tests/auth.py new file mode 100644 index 0000000..48fb079 --- /dev/null +++ b/userausfall/rest_api/tests/auth.py @@ -0,0 +1,68 @@ +from rest_framework import status + +from userausfall.models import User +from userausfall.rest_api.tests.userausfall import UserausfallAPITestCase +from userausfall.tests import UserMixin + + +class UserTestCase(UserMixin, UserausfallAPITestCase): + base_url = "/api/auth" + + def test_signup(self): + """ + Create a new user account (signup) + """ + url = "/register/" + username = "test" + password = "test12345" + data = {"username": username, "password": password, "passwordConfirm": password} + response = self.client.post(self.get_api_url(url), data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(User.objects.count(), 1) + self.assertEqual(User.objects.get().username, username) + + def test_login(self) -> None: + """ + Create a session cookie (login) + """ + url = "/login/" + self.create_user() + data = {"login": self.username, "password": self.password} + response = self.client.post(self.get_api_url(url), data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.wsgi_request.user.is_authenticated) + + def test_logout(self) -> None: + """ + Remove the session (logout) + """ + url = "/logout/" + self.authenticate_user() + response = self.client.post(self.get_api_url(url)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.wsgi_request.user.is_authenticated) + + def test_retrieve_user(self) -> None: + """ + Retrieve data of authenticated user + """ + url = "/profile/" + self.create_user() + self.authenticate_user() + response = self.client.get(self.get_api_url(url)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual( + response.data, + { + "username": self.username, + "id": self.user.id, + }, + ) + + def test_create_two_users(self) -> None: + """ + Create more than one user + """ + self.create_user() + self.create_user() + self.assertEqual(User.objects.count(), 2) diff --git a/userausfall/rest_api/tests/userausfall.py b/userausfall/rest_api/tests/userausfall.py new file mode 100644 index 0000000..30fd6ea --- /dev/null +++ b/userausfall/rest_api/tests/userausfall.py @@ -0,0 +1,13 @@ +from rest_framework.test import APITestCase + + +class UserausfallAPITestCase(APITestCase): + base_url = "/api" + + def get_api_url(self, url, **kwargs): + """ + Prepend the path to the full url for this test class. + :param url: an url fragment + :return: the url fragment prepended with the base url + """ + return f"{self.base_url}{url.format(**kwargs)}" diff --git a/userausfall/tests.py b/userausfall/tests.py new file mode 100644 index 0000000..ceb2f8b --- /dev/null +++ b/userausfall/tests.py @@ -0,0 +1,23 @@ +from userausfall.models import User + + +class UserMixin: + user: User + password: str + username: str + + def create_user(self): + self.username = f"test{User.objects.count()}" + self.password = "test12345" + self.user = User.objects.create_user(self.username, self.password) + + def ensure_user_exists(self): + if not hasattr(self, "user"): + self.create_user() + + def authenticate_user(self): + self.ensure_user_exists() + if hasattr(self.client, "force_authentication"): + self.client.force_authenticate(user=self.user) + else: + self.client.force_login(user=self.user)