Skip to content
Snippets Groups Projects
Commit 9acd565c authored by Erwan Rouchet's avatar Erwan Rouchet Committed by Bastien Abadie
Browse files

Demo authentication

parent cd466f78
No related branches found
No related tags found
No related merge requests found
Showing
with 380 additions and 69 deletions
......@@ -5,3 +5,4 @@ include tests-requirements.txt
include arkindex/documents/*.xsl
recursive-include arkindex/templates *.html
recursive-include arkindex/templates *.json
recursive-include arkindex/templates *.txt
......@@ -88,6 +88,14 @@ class CorpusList(ListAPIView):
return Corpus.objects.readable(self.request.user)
class PageDetails(RetrieveAPIView):
"""
Get details for a specific page
"""
serializer_class = PageLightSerializer
queryset = Page.objects.select_related('zone__image__server')
class SurfaceDetails(RetrieveAPIView):
"""
Get details for a specific surface
......
......@@ -349,6 +349,11 @@ class Page(Element):
return False
def __eq__(self, other):
# Prevent AttributeError when a Page is an Element and its subclass has not been used
if not isinstance(other, Page):
if not isinstance(other, Element) and hasattr(other, 'page'):
return False
other = other.page
return self.page_type == other.page_type \
and self.nb == other.nb \
and self.direction == other.direction \
......
......@@ -5,12 +5,14 @@ from arkindex.documents.api import \
VolumeManifest, ActManifest, \
PageAnnotationList, PageActAnnotationList, SurfaceAnnotationList, \
TranscriptionSearch, ActSearch, TranscriptionSearchAnnotationList, \
ActEdit, TranscriptionCreate, TranscriptionBulk, SurfaceDetails
ActEdit, TranscriptionCreate, TranscriptionBulk, PageDetails, SurfaceDetails
from arkindex.dataimport.api import \
DataImportsList, DataImportDetails, DataImportFailures, \
DataFileList, DataFileRetrieve, DataFileUpload, \
GitRepositoryImportHook, RepositoryList, AvailableRepositoriesList, RepositoryRetrieve, RepositoryStartImport
from arkindex.users.api import ProvidersList, CredentialsList, CredentialsRetrieve
from arkindex.users.api import \
ProvidersList, CredentialsList, CredentialsRetrieve, \
UserRetrieve, UserCreate, UserEmailLogin, UserEmailVerification
api = [
......@@ -22,8 +24,9 @@ api = [
url(r'elements/(?P<pk>[\w\-]+)/$',
RelatedElementsList.as_view(), name='related-elements'),
url(r'elements/$', ElementsList.as_view(), name='elements'),
url(r'page/(?P<pk>[\w\-]+)/?$', PageDetails.as_view(), name='page-details'),
url(r'surface/(?P<pk>[\w\-]+)/?$', SurfaceDetails.as_view(), name='surface-details'),
url(r'corpus/$', CorpusList.as_view(), name='corpus'),
url(r'corpus/?$', CorpusList.as_view(), name='corpus'),
# Manifests
url(r'^manifest/(?P<pk>[\w\-]+)/pages/?$',
......@@ -78,7 +81,7 @@ api = [
url(r'^imports/repos/search/(?P<pk>[\w\-]+)/?$',
AvailableRepositoriesList.as_view(),
name='available-repositories'),
url(r'^imports/(?P<pk>[\w\-]+)$', DataImportDetails.as_view(), name='import-details'),
url(r'^imports/(?P<pk>[\w\-]+)/?$', DataImportDetails.as_view(), name='import-details'),
url(r'^imports/(?P<pk>[\w\-]+)/failures$', DataImportFailures.as_view(), name='import-failures'),
url(r'^imports/files/(?P<pk>[\w\-]+)$', DataFileList.as_view(), name='file-list'),
url(r'^imports/file/(?P<pk>[\w\-]+)$', DataFileRetrieve.as_view(), name='file-retrieve'),
......@@ -89,4 +92,10 @@ api = [
url(r'^oauth/providers/?$', ProvidersList.as_view(), name='providers-list'),
url(r'^oauth/credentials/?$', CredentialsList.as_view(), name='credentials-list'),
url(r'^oauth/credentials/(?P<pk>[\w\-]+)/?$', CredentialsRetrieve.as_view(), name='credentials-retrieve'),
# Authentication
url(r'^user/?$', UserRetrieve.as_view(), name='user-retrieve'),
url(r'^user/new/?$', UserCreate.as_view(), name='user-new'),
url(r'^user/login/?$', UserEmailLogin.as_view(), name='user-login'),
url(r'^user/token/?$', UserEmailVerification.as_view(), name='user-token'),
]
......@@ -14,6 +14,15 @@ import os
import logging
import tempfile
def env2list(env_name, separator=','):
'''
Load env variable as a list
'''
value = os.environ.get(env_name)
return value and value.split(separator) or []
# Admins in charge
ADMINS = [
('Bastien', 'abadie@teklia.com'),
......@@ -33,8 +42,7 @@ SECRET_KEY = os.environ.get('SECRET_KEY', 'jf0w^y&ml(caax8f&a1mub)(js9(l5mhbbhos
ARKINDEX_ENV = os.environ.get('ARKINDEX_ENV', 'dev')
DEBUG = ARKINDEX_ENV == 'dev'
hosts = os.environ.get('ALLOWED_HOSTS')
ALLOWED_HOSTS = hosts and hosts.split(',') or []
ALLOWED_HOSTS = env2list('ALLOWED_HOSTS')
# Required for django-debug-toolbar
INTERNAL_IPS = ['127.0.0.1', '127.0.1.1']
......@@ -53,6 +61,7 @@ INSTALLED_APPS = [
'rest_framework',
'rest_framework.authtoken',
'webpack_loader',
'corsheaders',
# Our apps
'arkindex.images',
......@@ -62,6 +71,7 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
......@@ -148,11 +158,6 @@ USE_L10N = True
USE_TZ = True
# Cookies
CSRF_COOKIE_NAME = 'arkindex.csrf'
SESSION_COOKIE_NAME = 'arkindex.auth'
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/static-files/
FRONTEND_DIR = os.environ.get(
......@@ -344,6 +349,18 @@ if os.environ.get('EMAIL_HOST'):
else:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Cookies
CSRF_COOKIE_NAME = 'arkindex.csrf'
CSRF_TRUSTED_ORIGINS = env2list('CSRF_TRUSTED_ORIGINS')
CSRF_COOKIE_DOMAIN = os.environ.get('COOKIE_DOMAIN')
SESSION_COOKIE_NAME = 'arkindex.auth'
SESSION_COOKIE_DOMAIN = os.environ.get('COOKIE_DOMAIN')
CORS_ORIGIN_WHITELIST = env2list('CORS_ORIGIN_WHITELIST')
CORS_ALLOW_CREDENTIALS = True
CORS_URLS_REGEX = r'^/api/.*$'
# Optional unit tests runner with code coverage
try:
import django_nose # noqa
......@@ -369,11 +386,3 @@ try:
INSTALLED_APPS.append('debug_toolbar')
except ImportError:
pass
try:
import corsheaders # noqa
MIDDLEWARE.insert(0, 'corsheaders.middleware.CorsMiddleware')
INSTALLED_APPS.append('corsheaders')
CORS_ORIGIN_WHITELIST = ('localhost:5000', )
except ImportError:
pass
{% autoescape off %}
To verify your email address for your Arkindex account, click the link below:
{{ url }}
If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead.
--
Teklia
{% endautoescape %}
Arkindex email verification
......@@ -67,7 +67,7 @@ class UserAdmin(BaseUserAdmin):
list_display = ('email', 'is_admin')
list_filter = ('is_admin',)
fieldsets = (
(None, {'fields': ('email', 'password')}),
(None, {'fields': ('email', 'verified_email', 'password')}),
('Permissions', {'fields': ('is_admin',)}),
)
# add_fieldsets is not a standard ModelAdmin attribute. UserAdmin
......
from rest_framework.generics import ListAPIView, RetrieveDestroyAPIView
from django.urls import reverse
from django.core.mail import send_mail
from django.contrib.auth import login, logout
from django.contrib.auth.tokens import default_token_generator
from django.views.generic import RedirectView
from django.template.loader import render_to_string
from django.utils.http import urlsafe_base64_encode
from rest_framework.generics import ListAPIView, RetrieveDestroyAPIView, RetrieveUpdateDestroyAPIView, CreateAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import AuthenticationFailed, ValidationError
from arkindex.documents.models import Corpus
from arkindex.users.providers import oauth_providers
from arkindex.users.serializers import OAuthCredentialsSerializer, OAuthProviderClassSerializer
from arkindex.users.models import User
from arkindex.users.serializers import \
OAuthCredentialsSerializer, OAuthProviderClassSerializer, UserSerializer, NewUserSerializer, EmailLoginSerializer
import urllib.parse
import logging
logger = logging.getLogger(__name__)
class ProvidersList(ListAPIView):
......@@ -31,3 +46,86 @@ class CredentialsRetrieve(RetrieveDestroyAPIView):
def perform_destroy(self, instance):
instance.provider_class(request=self.request, credentials=instance).disconnect()
super().perform_destroy(instance)
class UserRetrieve(RetrieveUpdateDestroyAPIView):
permission_classes = (IsAuthenticated, )
serializer_class = UserSerializer
def get_object(self):
return self.request.user
def perform_destroy(self, instance):
logout(self.request)
class UserCreate(CreateAPIView):
serializer_class = NewUserSerializer
def perform_create(self, serializer):
user = serializer.save()
login(self.request, user)
corpus = Corpus.objects.create(name=user.email)
corpus.corpus_right.create(user=user, can_write=True, can_admin=True)
activation_url = '{}?{}'.format(
self.request.build_absolute_uri(reverse('api:user-token')),
urllib.parse.urlencode({
'email': user.email,
'token': default_token_generator.make_token(user),
}),
)
sent = send_mail(
# Subject cannot have any newlines
subject=''.join(render_to_string('registration/verification_email_subject.txt').splitlines()),
message=render_to_string(
'registration/verification_email.html',
context={
'url': activation_url,
},
request=self.request,
),
from_email=None,
recipient_list=[user.email],
fail_silently=True,
)
if sent == 0:
logger.error('Failed to send registration email to {}'.format(user.email))
class UserEmailLogin(CreateAPIView):
serializer_class = EmailLoginSerializer
def perform_create(self, serializer):
try:
user = User.objects.get_by_natural_key(serializer.validated_data['email'])
assert user.has_usable_password()
assert user.check_password(serializer.validated_data['password'])
except (User.DoesNotExist, AssertionError):
raise AuthenticationFailed()
login(self.request, user)
class UserEmailVerification(RedirectView):
def get_redirect_url(self):
if not all(arg in self.request.GET for arg in ('email', 'token')):
raise ValidationError()
try:
user = User.objects.get_by_natural_key(self.request.GET['email'])
assert default_token_generator.check_token(user, self.request.GET['token'])
except (User.DoesNotExist, AssertionError):
raise AuthenticationFailed()
user.verified_email = True
user.save()
if user.has_usable_password():
login(self.request, user)
return '/'
return reverse('password_reset_confirm', kwargs={
'uidb64': urlsafe_base64_encode(str(user.id).encode()).decode(),
'token': self.request.GET['token'],
})
# Generated by Django 2.1 on 2018-08-21 14:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0004_corpus_right'),
]
operations = [
migrations.AddField(
model_name='user',
name='verified_email',
field=models.BooleanField(default=False),
),
]
......@@ -13,6 +13,7 @@ class User(AbstractBaseUser):
)
is_active = models.BooleanField(default=True)
is_admin = models.BooleanField(default=False)
verified_email = models.BooleanField(default=False)
corpus = models.ManyToManyField('documents.Corpus', through='users.CorpusRight')
......
from rest_framework import serializers
from arkindex.users.models import OAuthCredentials
from arkindex.users.models import OAuthCredentials, User
class OAuthCredentialsSerializer(serializers.ModelSerializer):
......@@ -22,3 +22,44 @@ class OAuthProviderClassSerializer(serializers.Serializer):
name = serializers.CharField(source='__name__')
display_name = serializers.CharField()
default_url = serializers.URLField(source='url')
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = (
'id',
'email',
'password',
'is_admin',
)
extra_kwargs = {
'id': {'read_only': True},
'email': {'read_only': True},
'password': {'write_only': True},
'is_admin': {'read_only': True},
}
def update(self, instance, validated_data):
if 'password' in validated_data:
instance.set_password(validated_data.pop('password'))
return super().update(instance, validated_data)
class NewUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = (
'email',
)
def create(self, validated_data):
return User.objects.create_user(validated_data['email'])
class EmailLoginSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(write_only=True)
from django.test import TestCase
from django.contrib.auth.models import AnonymousUser
from arkindex.documents.models import Corpus
from arkindex.users.models import User
class TestACL(TestCase):
'''
Test Corpus ACL
'''
def setUp(self):
self.anon = AnonymousUser()
self.admin = User.objects.create_superuser('admin@address.com', 'P4$5w0Rd')
self.user = User.objects.create_user('user@address.com', 'P4$5w0Rd')
self.corpus_public = Corpus.objects.create(name='A Public', public=True)
self.corpus_private = Corpus.objects.create(name='B Private')
self.user.corpus_right.create(corpus=self.corpus_private, user=self.user, can_write=True)
self.corpus_hidden = Corpus.objects.create(name='C Hidden')
def test_anon(self):
# An anonymous user has only access to public
self.assertQuerysetEqual(
Corpus.objects.readable(self.anon),
[self.corpus_public.id],
transform=lambda x: x.id,
)
def test_user(self):
# An anonymous user has access to public & private
self.assertFalse(self.user.is_admin)
self.assertQuerysetEqual(
Corpus.objects.readable(self.user),
[self.corpus_public.id, self.corpus_private.id],
transform=lambda x: x.id,
)
def test_admin(self):
# An admin has access to all
self.assertTrue(self.admin.is_admin)
self.assertQuerysetEqual(
Corpus.objects.readable(self.admin),
Corpus.objects.order_by('name').values_list('id', flat=True),
transform=lambda x: x.id,
)
......@@ -3,15 +3,14 @@ from django.urls import reverse
from django.core import mail
from django.contrib import auth
from arkindex.users.models import User
from django.contrib.auth.models import AnonymousUser
from arkindex.documents.models import Corpus
from rest_framework.authtoken.models import Token
class TestUsers(TestCase):
def setUp(self):
self.user = User.objects.create_user('email@address.com', 'P4$5w0Rd')
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user('email@address.com', 'P4$5w0Rd')
def test_login_success(self):
response = self.client.post(
......@@ -50,45 +49,3 @@ class TestUsers(TestCase):
Check creating a user automatically creates a token
"""
self.assertEqual(Token.objects.filter(user=self.user).count(), 1)
class TestACL(TestCase):
'''
Test Corpus ACL
'''
def setUp(self):
self.anon = AnonymousUser()
self.admin = User.objects.create_superuser('admin@address.com', 'P4$5w0Rd')
self.user = User.objects.create_user('user@address.com', 'P4$5w0Rd')
self.corpus_public = Corpus.objects.create(name='A Public', public=True)
self.corpus_private = Corpus.objects.create(name='B Private')
self.user.corpus_right.create(corpus=self.corpus_private, user=self.user, can_write=True)
self.corpus_hidden = Corpus.objects.create(name='C Hidden')
def test_anon(self):
# An anonymous user has only access to public
self.assertQuerysetEqual(
Corpus.objects.readable(self.anon),
[self.corpus_public.id],
transform=lambda x: x.id,
)
def test_user(self):
# An anonymous user has access to public & private
self.assertFalse(self.user.is_admin)
self.assertQuerysetEqual(
Corpus.objects.readable(self.user),
[self.corpus_public.id, self.corpus_private.id],
transform=lambda x: x.id,
)
def test_admin(self):
# An admin has access to all
self.assertTrue(self.admin.is_admin)
self.assertQuerysetEqual(
Corpus.objects.readable(self.admin),
Corpus.objects.order_by('name').values_list('id', flat=True),
transform=lambda x: x.id,
)
from rest_framework.test import APITestCase
from rest_framework import status
from django.urls import reverse
from django.core import mail
from django.contrib import auth
from django.contrib.auth.tokens import default_token_generator
from arkindex.users.models import User
import urllib.parse
class TestRegistration(APITestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user('email@address.com', 'P4$5w0Rd')
def test_retrieve_requires_login(self):
response = self.client.get(reverse('api:user-retrieve'))
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_update_requires_login(self):
response = self.client.put(
reverse('api:user-retrieve'),
data={'password': 'N€wP4$5w0Rd'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_logout_requires_login(self):
response = self.client.delete(reverse('api:user-retrieve'))
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_retrieve(self):
self.client.force_login(self.user)
response = self.client.get(reverse('api:user-retrieve'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(data['email'], 'email@address.com')
self.assertEqual(data['is_admin'], False)
self.assertNotIn('password', data)
def test_update(self):
self.client.force_login(self.user)
response = self.client.put(
reverse('api:user-retrieve'),
data={'password': 'N€wP4$5w0Rd'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.user.refresh_from_db()
self.assertTrue(self.user.check_password('N€wP4$5w0Rd'))
def test_logout(self):
self.client.force_login(self.user)
response = self.client.delete(reverse('api:user-retrieve'))
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(auth.get_user(self.client).is_authenticated)
def test_create(self):
response = self.client.post(
reverse('api:user-new'),
data={'email': 'newuser@example.com'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertTrue(auth.get_user(self.client).is_authenticated)
self.assertEqual(auth.get_user(self.client).email, 'newuser@example.com')
self.assertFalse(User.objects.get_by_natural_key('newuser@example.com').verified_email)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ['newuser@example.com'])
def test_login_failure(self):
response = self.client.post(
reverse('api:user-login'),
data={'email': 'email@address.com', 'password': 'wrongpassword'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(auth.get_user(self.client).is_authenticated)
def test_login(self):
response = self.client.post(
reverse('api:user-login'),
data={'email': 'email@address.com', 'password': 'P4$5w0Rd'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertTrue(auth.get_user(self.client).is_authenticated)
self.assertEqual(auth.get_user(self.client).email, 'email@address.com')
def test_email_verification(self):
newuser = User.objects.create_user('newuser@example.com')
self.assertFalse(newuser.verified_email)
self.assertFalse(newuser.has_usable_password())
response = self.client.get('{}?{}'.format(
reverse('api:user-token'),
urllib.parse.urlencode({
'email': 'newuser@example.com',
'token': default_token_generator.make_token(newuser),
})),
)
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
newuser.refresh_from_db()
self.assertTrue(newuser.verified_email)
......@@ -4,6 +4,7 @@ celery==4.2.0
celery_once==2.0.0
certifi==2017.7.27.1
chardet==3.0.4
django-cors-headers==2.4.0
django-enumfields==0.10.0
djangorestframework==3.7.1
django-webpack-loader==0.5.0
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment