Compare commits
6 commits
517f79c9f9
...
b4c686bfaa
Author | SHA1 | Date | |
---|---|---|---|
b4c686bfaa | |||
a887be1368 | |||
ceb032dddb | |||
79b7bc8364 | |||
96f4a81a3b | |||
5206f95e3f |
27 changed files with 370 additions and 368 deletions
17
.editorconfig
Normal file
17
.editorconfig
Normal file
|
@ -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
|
46
.gitignore
vendored
46
.gitignore
vendored
|
@ -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__/
|
__pycache__/
|
||||||
|
|
||||||
|
/data/*
|
||||||
|
!/data/.gitkeep
|
||||||
/db.sqlite3
|
/db.sqlite3
|
||||||
/venv/
|
/media/
|
||||||
|
|
||||||
|
/dist/
|
||||||
|
*.egg-info/
|
||||||
|
/build/
|
||||||
|
/.pybuild/
|
||||||
|
/.tox/
|
||||||
|
.coverage*
|
||||||
/debian/*debhelper*
|
/debian/*debhelper*
|
||||||
/debian/*.substvars
|
/debian/*.substvars
|
||||||
/debian/files
|
/debian/files
|
||||||
/debian/python3-userausfall/
|
/debian/python3-userausfall/
|
||||||
/debian/userausfall/
|
/debian/userausfall/
|
||||||
/debian/userausfall-webapp/
|
/debian/userausfall-webapp/
|
||||||
/.pybuild/
|
|
||||||
/build/
|
node_modules/
|
||||||
/userausfall.egg-info/
|
app/dist/
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
*.env*
|
||||||
|
*.swp
|
||||||
|
|
|
@ -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")
|
|
13
pyproject.toml
Normal file
13
pyproject.toml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
[tool.black]
|
||||||
|
line-length = 120
|
||||||
|
target-version = ['py39']
|
||||||
|
exclude = '''
|
||||||
|
(
|
||||||
|
/(
|
||||||
|
\.eggs
|
||||||
|
| \.git
|
||||||
|
| \.tox
|
||||||
|
| migrations
|
||||||
|
)
|
||||||
|
)
|
||||||
|
'''
|
|
@ -1,3 +0,0 @@
|
||||||
setuptools~=40.8.0
|
|
||||||
django~=2.2.13
|
|
||||||
djangorestframework~=3.9.0
|
|
7
setup.cfg
Normal file
7
setup.cfg
Normal file
|
@ -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
|
44
setup.py
44
setup.py
|
@ -1,19 +1,45 @@
|
||||||
from setuptools import setup, find_packages
|
import os
|
||||||
|
|
||||||
|
from setuptools import find_namespace_packages, setup
|
||||||
|
|
||||||
from userausfall import __version__
|
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(
|
setup(
|
||||||
name="userausfall",
|
name="userausfall",
|
||||||
version=__version__,
|
version=__version__,
|
||||||
description="account management for systemausfall.org",
|
description="account management for systemausfall.org",
|
||||||
|
long_description=long_description,
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
url="https://git.systemausfall.org/systemausfall.org/userausfall",
|
url="https://git.systemausfall.org/systemausfall.org/userausfall",
|
||||||
author="Robert Waltemath",
|
author="userausfall developers",
|
||||||
author_email="rw@roko.li",
|
author_email="hallo@roko.li",
|
||||||
packages=find_packages(),
|
license="AGPL-3.0-or-later",
|
||||||
install_requires=(
|
packages=find_namespace_packages(include=["userausfall"]),
|
||||||
"django>=2.2<3.0",
|
install_requires=[
|
||||||
# "djangorestframework>=3.12<4.0",
|
"django~=3.2.8",
|
||||||
# "djoser>=2.1<3.0",
|
"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,
|
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",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
25
tox.ini
Normal file
25
tox.ini
Normal file
|
@ -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
|
||||||
|
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
|
|
@ -1,3 +1 @@
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
default_app_config = 'userausfall.apps.UserausfallConfig'
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from userausfall.models import User, TrustBridge
|
from userausfall.models import TrustBridge, User
|
||||||
|
|
||||||
admin.site.register(TrustBridge)
|
admin.site.register(TrustBridge)
|
||||||
admin.site.register(User)
|
admin.site.register(User)
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class UserausfallConfig(AppConfig):
|
|
||||||
name = 'userausfall'
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
from . import signals
|
|
|
@ -1,17 +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()
|
|
|
@ -1,7 +1,7 @@
|
||||||
from djeveric import Email
|
from djeveric.emails import ConfirmationEmail
|
||||||
|
|
||||||
|
|
||||||
class ConfidantConfirmationEmail(Email):
|
class ConfidantConfirmationEmail(ConfirmationEmail):
|
||||||
subject = "TODO"
|
subject = "TODO"
|
||||||
|
|
||||||
def get_message(self, context):
|
def get_message(self, context):
|
||||||
|
|
|
@ -1,8 +1,36 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from ldap3 import Server, Connection, SYNC
|
from ldap3 import Connection, Server, SYNC
|
||||||
|
|
||||||
|
|
||||||
def create_account(username, raw_password):
|
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")
|
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
|
# 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).
|
# soon as it is available (multithreading).
|
||||||
|
@ -13,9 +41,4 @@ def create_account(username, raw_password):
|
||||||
client_strategy=SYNC,
|
client_strategy=SYNC,
|
||||||
auto_bind=True,
|
auto_bind=True,
|
||||||
)
|
)
|
||||||
is_success = connection.add(
|
return connection
|
||||||
f"cn={username},dc=local",
|
|
||||||
["simpleSecurityObject", "organizationalRole"],
|
|
||||||
{"userPassword": raw_password},
|
|
||||||
)
|
|
||||||
return is_success
|
|
||||||
|
|
|
@ -52,15 +52,13 @@ class UserManager(BaseUserManager):
|
||||||
return self._create_user(email, password, **extra_fields)
|
return self._create_user(email, password, **extra_fields)
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractBaseUser, PermissionsMixin):
|
class User(PermissionsMixin, AbstractBaseUser):
|
||||||
username_validator = UnicodeUsernameValidator()
|
username_validator = UnicodeUsernameValidator()
|
||||||
|
|
||||||
username = models.CharField(
|
username = models.CharField(
|
||||||
_("username"),
|
_("username"),
|
||||||
max_length=150,
|
max_length=150,
|
||||||
help_text=_(
|
help_text=_("Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."),
|
||||||
"Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
|
|
||||||
),
|
|
||||||
validators=[username_validator],
|
validators=[username_validator],
|
||||||
error_messages={"unique": _("A user with that username already exists.")},
|
error_messages={"unique": _("A user with that username already exists.")},
|
||||||
unique=True,
|
unique=True,
|
||||||
|
@ -96,9 +94,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||||
if not self.username:
|
if not self.username:
|
||||||
raise MissingUserAttribute("User is missing a username.")
|
raise MissingUserAttribute("User is missing a username.")
|
||||||
if not self.check_password(raw_password):
|
if not self.check_password(raw_password):
|
||||||
raise PasswordMismatch(
|
raise PasswordMismatch("The given password does not match the user's password.")
|
||||||
"The given password does not match the user's password."
|
|
||||||
)
|
|
||||||
return ldap.create_account(self.username, raw_password)
|
return ldap.create_account(self.username, raw_password)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
|
@ -1,6 +1,6 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from userausfall.models import User, TrustBridge
|
from userausfall.models import TrustBridge, User
|
||||||
|
|
||||||
|
|
||||||
class TrustBridgeSerializer(serializers.ModelSerializer):
|
class TrustBridgeSerializer(serializers.ModelSerializer):
|
||||||
|
|
1
userausfall/rest_api/tests/__init__.py
Normal file
1
userausfall/rest_api/tests/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .auth import * # noqa: F401, F403
|
68
userausfall/rest_api/tests/auth.py
Normal file
68
userausfall/rest_api/tests/auth.py
Normal file
|
@ -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)
|
13
userausfall/rest_api/tests/userausfall.py
Normal file
13
userausfall/rest_api/tests/userausfall.py
Normal file
|
@ -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)}"
|
|
@ -1,13 +1,13 @@
|
||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
|
|
||||||
from userausfall.rest_api.views import UserViewSet
|
router = routers.SimpleRouter()
|
||||||
|
|
||||||
router = routers.DefaultRouter(trailing_slash=True)
|
|
||||||
router.register(r'users', UserViewSet, basename="user")
|
|
||||||
|
|
||||||
urlpatterns = [
|
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
|
|
||||||
|
|
|
@ -1,26 +1,11 @@
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
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,
|
|
||||||
CreateUserSerializer,
|
|
||||||
RetrieveUserSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UserViewSet(viewsets.ModelViewSet):
|
class UserViewSet(viewsets.GenericViewSet):
|
||||||
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)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
def activate(self, request, pk=None):
|
def activate(self, request, pk=None):
|
||||||
"""Create the corresponding LDAP account."""
|
"""Create the corresponding LDAP account."""
|
||||||
|
@ -38,11 +23,3 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
else:
|
else:
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
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
|
|
||||||
|
|
|
@ -2,24 +2,25 @@
|
||||||
Django settings for userausfall.
|
Django settings for userausfall.
|
||||||
|
|
||||||
For more information on this file, see
|
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
|
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
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
DATA_DIR = os.environ.get("USERAUSFALL_DATA_DIR", BASE_DIR)
|
DATA_DIR = Path(os.environ.get("USERAUSFALL_DATA_DIR", BASE_DIR))
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# 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!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
@ -30,80 +31,78 @@ ALLOWED_HOSTS = []
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
'django.contrib.auth',
|
"django.contrib.auth",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.sessions',
|
"django.contrib.sessions",
|
||||||
'django.contrib.messages',
|
"django.contrib.messages",
|
||||||
'django.contrib.sites',
|
"django.contrib.sites",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
'userausfall',
|
"rest_framework",
|
||||||
'rest_framework',
|
"rest_registration",
|
||||||
|
"django_filters",
|
||||||
|
"drf_spectacular",
|
||||||
|
"userausfall",
|
||||||
|
"userausfall.rest_api",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'userausfall.urls'
|
ROOT_URLCONF = "userausfall.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [],
|
"DIRS": [],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": True,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.debug',
|
"django.template.context_processors.debug",
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.messages.context_processors.messages",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'userausfall.wsgi.application'
|
WSGI_APPLICATION = "userausfall.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
'NAME': os.path.join(DATA_DIR, 'db.sqlite3'),
|
"NAME": DATA_DIR / "db.sqlite3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# User model
|
|
||||||
#
|
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'userausfall.User'
|
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
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 +110,9 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
# 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
|
USE_I18N = True
|
||||||
|
|
||||||
|
@ -125,30 +124,69 @@ USE_TZ = True
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
|
|
||||||
# Media files
|
# Media files
|
||||||
# https://docs.djangoproject.com/en/2.2/topics/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/'
|
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
|
# Sending email
|
||||||
# https://docs.djangoproject.com/en/3.2/topics/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
|
# Django Rest Framework
|
||||||
# https://www.django-rest-framework.org/api-guide/settings/
|
# https://www.django-rest-framework.org/api-guide/settings/
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
"DEFAULT_FILTER_BACKENDS": [
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
"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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -159,6 +197,6 @@ SITE_ID = 1
|
||||||
|
|
||||||
|
|
||||||
USERAUSFALL_LDAP = {
|
USERAUSFALL_LDAP = {
|
||||||
'ADMIN_USER_DN': 'cn=admin,dc=local',
|
"ADMIN_USER_DN": "cn=admin,dc=local",
|
||||||
'ADMIN_USER_PASSWORD': os.environ.get('USERAUSFALL_LDAP_PASSWORD'),
|
"ADMIN_USER_PASSWORD": os.environ.get("USERAUSFALL_LDAP_PASSWORD"),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
23
userausfall/tests.py
Normal file
23
userausfall/tests.py
Normal file
|
@ -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)
|
|
@ -1,10 +1,7 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import include, path
|
||||||
|
|
||||||
from userausfall.rest_api import urls as rest_api_urls
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path('api/', include("userausfall.rest_api.urls")),
|
path("api/", include("userausfall.rest_api.urls")),
|
||||||
path("api-auth/", include("rest_framework.urls")),
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -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``.
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
For more information on this file, see
|
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
|
import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
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()
|
application = get_wsgi_application()
|
||||||
|
|
Reference in a new issue