diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/accounts/__pycache__/__init__.cpython-38.pyc b/accounts/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41d93e6c9a5dcabe7e615fa96edba5706d77ec40 Binary files /dev/null and b/accounts/__pycache__/__init__.cpython-38.pyc differ diff --git a/accounts/__pycache__/models.cpython-38.pyc b/accounts/__pycache__/models.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14ccae5ac3f214c95c4f440763bc6c9dcfd810ff Binary files /dev/null and b/accounts/__pycache__/models.cpython-38.pyc differ diff --git a/accounts/__pycache__/urls.cpython-38.pyc b/accounts/__pycache__/urls.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4cdf15b637821cfa8f7c6918092f72e947298d58 Binary files /dev/null and b/accounts/__pycache__/urls.cpython-38.pyc differ diff --git a/accounts/__pycache__/views.cpython-38.pyc b/accounts/__pycache__/views.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f62937f7402a3843006c4d94b2d6cedc57d01984 Binary files /dev/null and b/accounts/__pycache__/views.cpython-38.pyc differ diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8c38f3f3dad51e4585f3984282c2a4bec5349c1e --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..9b3fc5a44939430bfb326ca9a33f80e99b06b5be --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/accounts/authentication.py b/accounts/authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..e899cfb2d0465b95372cf1743bdee2115acc8fbd --- /dev/null +++ b/accounts/authentication.py @@ -0,0 +1,22 @@ +import sys +from accounts.models import ListUser, Token + + +class PasswordlessAuthenticationBackend(object): + def authenticate(self, uid): + print('uid', uid, file=sys.stderr) + if not Token.objects.filter(uid=uid).exists(): + print('no token found', file=sys.stderr) + return None + token = Token.objects.get(uid=uid) + print('got token', file=sys.stderr) + try: + user = ListUser.objects.get(email=token.email) + print('got user', file=sys.stderr) + return user + except ListUser.DoesNotExist: + print('new user', file=sys.stderr) + return ListUser.objects.create(email=token.email) + + def get_user(self, email): + return ListUser.objects.get(email=email) \ No newline at end of file diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..c0807a32d9c52dd215327a47f7a090b8f9f5b64b --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.24 on 2019-11-14 16:29 +from __future__ import unicode_literals + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('email', models.EmailField(max_length=254, primary_key=True, serialize=False)), + ], + ), + migrations.CreateModel( + name='ListUser', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('email', models.EmailField(max_length=254, primary_key=True, serialize=False)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Token', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('uid', models.CharField(default=uuid.uuid4, max_length=40)), + ], + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/accounts/migrations/__pycache__/0001_initial.cpython-38.pyc b/accounts/migrations/__pycache__/0001_initial.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4bf8a84389ea0ae2c778dbfc73e8eff572013b3a Binary files /dev/null and b/accounts/migrations/__pycache__/0001_initial.cpython-38.pyc differ diff --git a/accounts/migrations/__pycache__/__init__.cpython-38.pyc b/accounts/migrations/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..64736dd220e5a06c0e9d9375966bed4434e70c59 Binary files /dev/null and b/accounts/migrations/__pycache__/__init__.cpython-38.pyc differ diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000000000000000000000000000000000000..249d6fdf45a462d7dcbff25af5e0ef10bbc003f2 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,36 @@ +from django.db import models +import uuid +from django.contrib.auth.models import ( +AbstractBaseUser, BaseUserManager, PermissionsMixin +) + +class Token(models.Model): + email = models.EmailField() + uid = models.CharField(default=uuid.uuid4, max_length=40) + + +class ListUserManager(BaseUserManager): + def create_user(self, email): + ListUser.objects.create(email=email) + def create_superuser(self, email, password): + self.create_user(email) + + +class ListUser(AbstractBaseUser, PermissionsMixin): + email = models.EmailField(primary_key=True) + USERNAME_FIELD = 'email' + #REQUIRED_FIELDS = ['email', 'height'] + objects = ListUserManager() + @property + def is_staff(self): + return self.email == 'harry.percival@example.com' + @property + def is_active(self): + return True + +class User(models.Model): + email = models.EmailField(primary_key=True) + REQUIRED_FIELDS = [] + USERNAME_FIELD = 'email' + is_anonymous = False + is_authenticated = True \ No newline at end of file diff --git a/accounts/templates/login_email_sent.html b/accounts/templates/login_email_sent.html new file mode 100644 index 0000000000000000000000000000000000000000..5dfbf159abc8b91439fdc1f6e30cf21cde1cad05 --- /dev/null +++ b/accounts/templates/login_email_sent.html @@ -0,0 +1,5 @@ + +

Email sent

+

Check your email, you'll find a message with a link that will log you into +the site.

+ \ No newline at end of file diff --git a/accounts/tests/tests_models.py b/accounts/tests/tests_models.py new file mode 100644 index 0000000000000000000000000000000000000000..3aa94991796a0eb48886ae6addebb323ee4436ec --- /dev/null +++ b/accounts/tests/tests_models.py @@ -0,0 +1,16 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from accounts.models import Token + +User = get_user_model() + +class TokenModelTest(TestCase): + def test_links_user_with_auto_generated_uid(self): + token1 = Token.objects.create(email='a@b.com') + token2 = Token.objects.create(email='a@b.com') + self.assertNotEqual(token1.uid, token2.uid) + +class UserModelTest(TestCase): + def test_email_is_primary_key(self): + user = User(email='a@b.com') + self.assertEqual(user.pk, 'a@b.com') \ No newline at end of file diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..11defae1a6436ed5106158686ad410826a6273a2 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import url +from accounts import views + +urlpatterns = [ +url(r'^send_email$', views.send_login_email, name='send_login_email'), +url(r'^login$', views.login, name='login'), +url(r'^logout$', views.logout, name='logout'), +] \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000000000000000000000000000000000000..16e14bd8265328c61222d275a6628ec7963e06db --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,34 @@ +import uuid +import sys +from accounts.models import Token +from django.contrib.auth import authenticate +from django.contrib.auth import login as auth_login +from django.core.mail import send_mail +from django.shortcuts import redirect, render +from django.contrib.auth import login as auth_login, logout as auth_logout + +def send_login_email(request): + email = request.POST['email'] + uid = str(uuid.uuid4()) + Token.objects.create(email=email, uid=uid) + print('saving uid', uid, 'for email', email, file=sys.stderr) + url = request.build_absolute_uri(f'/accounts/login?uid={uid}') + send_mail( + 'Your login link for Superlists', + f'Use this link to log in:\n\n{url}', + 'noreply@superlists', + [email], + ) + return render(request, 'login_email_sent.html') + +def login(request): + print('login view', file=sys.stderr) + uid = request.GET.get('uid') + user = authenticate(uid=uid) + if user is not None: + auth_login(request, user) + return redirect('/') + +def logout(request): + auth_logout(request) + return redirect('/') \ No newline at end of file diff --git a/functional_tests/__pycache__/test_layout_and_styling.cpython-38.pyc b/functional_tests/__pycache__/test_layout_and_styling.cpython-38.pyc deleted file mode 100644 index e946463659fe69d4f57c70eaea0e7991d38c4627..0000000000000000000000000000000000000000 Binary files a/functional_tests/__pycache__/test_layout_and_styling.cpython-38.pyc and /dev/null differ diff --git a/functional_tests/__pycache__/test_list_item_validation.cpython-38.pyc b/functional_tests/__pycache__/test_list_item_validation.cpython-38.pyc deleted file mode 100644 index 02d94cdcfba46af1242205265bd60b148abd9568..0000000000000000000000000000000000000000 Binary files a/functional_tests/__pycache__/test_list_item_validation.cpython-38.pyc and /dev/null differ diff --git a/functional_tests/__pycache__/test_login.cpython-38.pyc b/functional_tests/__pycache__/test_login.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52ac3aea7f1822d904bbfda12f54a1ec3a017d05 Binary files /dev/null and b/functional_tests/__pycache__/test_login.cpython-38.pyc differ diff --git a/functional_tests/__pycache__/test_simple_list_creation.cpython-38.pyc b/functional_tests/__pycache__/test_simple_list_creation.cpython-38.pyc deleted file mode 100644 index dd48b6200ab12ecfbd4d9257708a7fe2ca58c07c..0000000000000000000000000000000000000000 Binary files a/functional_tests/__pycache__/test_simple_list_creation.cpython-38.pyc and /dev/null differ diff --git a/functional_tests/__pycache__/tests.cpython-38.pyc b/functional_tests/__pycache__/tests.cpython-38.pyc deleted file mode 100644 index 5da2e2d50fc8b00c1c33a7b4e18e7de76b4df5d2..0000000000000000000000000000000000000000 Binary files a/functional_tests/__pycache__/tests.cpython-38.pyc and /dev/null differ diff --git a/functional_tests/test_login.py b/functional_tests/test_login.py new file mode 100644 index 0000000000000000000000000000000000000000..d058d0b1987461a7127d66c44a063cc32c64f905 --- /dev/null +++ b/functional_tests/test_login.py @@ -0,0 +1,39 @@ +from django.core import mail +from selenium.webdriver.common.keys import Keys +import re +from .base import FunctionalTest +TEST_EMAIL = 'edith@example.com' +SUBJECT = 'Your login link for Superlists' + +class LoginTest(FunctionalTest): + def test_can_get_email_link_to_log_in(self): + # Edith goes to the awesome superlists site + # and notices a "Log in" section in the navbar for the first time + # It's telling her to enter her email address, so she does + self.browser.get(self.live_server_url) + self.browser.find_element_by_name('email').send_keys(TEST_EMAIL) + self.browser.find_element_by_name('email').send_keys(Keys.ENTER) + # A message appears telling her an email has been sent + self.wait_for(lambda: self.assertIn( + 'Check your email', + self.browser.find_element_by_tag_name('body').text + )) + # She checks her email and finds a message + email = mail.outbox[0] + self.assertIn(TEST_EMAIL, email.to) + self.assertEqual(email.subject, SUBJECT) + # It has a url link in it + self.assertIn('Use this link to log in', email.body) + url_search = re.search(r'http://.+/.+$', email.body) + if not url_search: + self.fail(f'Could not find url in email body:\n{email.body}') + url = url_search.group(0) + self.assertIn(self.live_server_url, url) + # she clicks it + self.browser.get(url) + # she is logged in! + self.wait_for( + lambda: self.browser.find_element_by_link_text('Log out') + ) + navbar = self.browser.find_element_by_css_selector('.navbar') + self.assertIn(TEST_EMAIL, navbar.text) diff --git a/lists/__pycache__/views.cpython-38.pyc b/lists/__pycache__/views.cpython-38.pyc index ffa0cd182ad3db3e663a231f183559267de1ab6e..312b969b498e211965a42779a4cffa718f42c012 100644 Binary files a/lists/__pycache__/views.cpython-38.pyc and b/lists/__pycache__/views.cpython-38.pyc differ diff --git a/lists/templates/base.html b/lists/templates/base.html index 5eb8d7d9a160cb86a0bdcca4f6dbaefad489b77b..12ca8eaf802da6152ce75cf5ca14bc6a4a4fb88d 100644 --- a/lists/templates/base.html +++ b/lists/templates/base.html @@ -7,19 +7,20 @@ -
-
-
-
-

{% block header_text %}{% endblock %}

-
- - {% csrf_token %} -
-
-
+ +
+ +
{% block table %} @@ -27,4 +28,5 @@
+ diff --git a/superlists/__pycache__/settings.cpython-38.pyc b/superlists/__pycache__/settings.cpython-38.pyc index d2166de9c4d0e671fb9a86b5effd4bd4d8f509df..d52927f96b1f6e1179416cf4961330486ca82bff 100644 Binary files a/superlists/__pycache__/settings.cpython-38.pyc and b/superlists/__pycache__/settings.cpython-38.pyc differ diff --git a/superlists/__pycache__/urls.cpython-38.pyc b/superlists/__pycache__/urls.cpython-38.pyc index 5163b783d14dddc89fcc313e803a314687c69fad..7194bff8d0a21347c7603bca2ba2db19db371714 100644 Binary files a/superlists/__pycache__/urls.cpython-38.pyc and b/superlists/__pycache__/urls.cpython-38.pyc differ diff --git a/superlists/settings.py b/superlists/settings.py index 675b69ff5edca4a65d18191066c70981eaa61b9e..9e0a5027b5a52de6893387c3b303379f5d68aaa0 100644 --- a/superlists/settings.py +++ b/superlists/settings.py @@ -37,7 +37,14 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'lists' + 'lists', + 'accounts', +] + +AUTH_USER_MODEL = 'accounts.User' + +AUTHENTICATION_BACKENDS = [ + 'accounts.authentication.PasswordlessAuthenticationBackend', ] MIDDLEWARE = [ @@ -114,10 +121,32 @@ USE_L10N = True USE_TZ = True +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_HOST_USER = 'obeythetestinggoat@gmail.com' +EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_PASSWORD') +EMAIL_PORT = 587 +EMAIL_USE_TLS = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.2/howto/static-files/ +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + }, + }, + 'root': {'level': 'INFO'}, +} + STATIC_URL = '/static/' STATICFILES_DIRS = [ os.path.join(BASE_DIR, "static"), diff --git a/superlists/urls.py b/superlists/urls.py index 6f504bf0cec2afddc9830448d85f5f79fc3130e1..e0de8b91fd27e4d8a13e213a37449a4b799245ae 100644 --- a/superlists/urls.py +++ b/superlists/urls.py @@ -1,8 +1,10 @@ from django.conf.urls import include, url from lists import views as list_views from lists import urls as list_urls +from accounts import urls as accounts_urls urlpatterns = [ -url(r'^$', list_views.home_page, name='home'), -url(r'^lists/', include(list_urls)), + url(r'^$', list_views.home_page, name='home'), + url(r'^lists/', include(list_urls)), + url(r'^accounts/', include(accounts_urls)), ]