diff --git a/homepage/accounts/__init__.py b/homepage/accounts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/homepage/accounts/admin.py b/homepage/accounts/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8c38f3f3dad51e4585f3984282c2a4bec5349c1e --- /dev/null +++ b/homepage/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/homepage/accounts/apps.py b/homepage/accounts/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..9b3fc5a44939430bfb326ca9a33f80e99b06b5be --- /dev/null +++ b/homepage/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/homepage/accounts/authentication.py b/homepage/accounts/authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..c6c24a65b4b19c59722f83068f634773b9715fa0 --- /dev/null +++ b/homepage/accounts/authentication.py @@ -0,0 +1,23 @@ +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/homepage/accounts/models.py b/homepage/accounts/models.py new file mode 100644 index 0000000000000000000000000000000000000000..ec1795ac101f9dfedf83d6037c4099a4b8e15013 --- /dev/null +++ b/homepage/accounts/models.py @@ -0,0 +1,32 @@ +from django.db import models + +class Token(models.Model): + email = models.EmailField() + uid = models.CharField(max_length=255) + +from django.contrib.auth.models import ( + AbstractBaseUser, BaseUserManager, PermissionsMixin +) + +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 \ No newline at end of file diff --git a/homepage/accounts/templates/login_email_sent.html b/homepage/accounts/templates/login_email_sent.html new file mode 100644 index 0000000000000000000000000000000000000000..ea8ebfcc36fbb8dec7be66a9c85b91a4c0f64b2e --- /dev/null +++ b/homepage/accounts/templates/login_email_sent.html @@ -0,0 +1,7 @@ +<html> +<h1>Email sent</h1> + +<p>Check your email, you'll find a message with a link that will log you into +the site.</p> + +</html> \ No newline at end of file diff --git a/homepage/accounts/tests.py b/homepage/accounts/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/homepage/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/homepage/accounts/urls.py b/homepage/accounts/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..1027713d863150840a87c375d58301fbd5c06ed4 --- /dev/null +++ b/homepage/accounts/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from accounts import views + +urlpatterns = [ + path('send_email', views.send_login_email, name='send_login_email'), + path('login', views.login, name='login'), + path('logout', views.logout, name='logout'), +] \ No newline at end of file diff --git a/homepage/accounts/views.py b/homepage/accounts/views.py new file mode 100644 index 0000000000000000000000000000000000000000..1e829adb02009a4595eb636c06191e8743bc9802 --- /dev/null +++ b/homepage/accounts/views.py @@ -0,0 +1,36 @@ +import uuid +import sys +from django.contrib.auth import authenticate +from django.contrib.auth import login as auth_login, logout as auth_logout +from django.core.mail import send_mail +from django.shortcuts import redirect, render +import os + +from accounts.models import Token + + +def send_login_email(request): + email = request.POST.get('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/homepage/functional_tests/base.py b/homepage/functional_tests/base.py new file mode 100644 index 0000000000000000000000000000000000000000..f75c65c83e30448283504527f029a505b98cc4f9 --- /dev/null +++ b/homepage/functional_tests/base.py @@ -0,0 +1,32 @@ +from django.test import LiveServerTestCase +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.keys import Keys +import unittest +import os +import time + + +class FunctionalTest(LiveServerTestCase): + def setUp(self): + options = Options() + options.add_argument('--dns-prefetch-disable') + options.add_argument('--no-sandbox') + options.add_argument('--headless') + options.add_argument('disable-gpu') + self.browser = webdriver.Chrome(executable_path="../chromedriver", options=options) + self.base_url = os.getenv("APP_URL") + + def tearDown(self): + self.browser.quit() + + def wait_for(self, condition_function): + start_time = time.time() + while time.time() < start_time + 3: + if condition_function(): + return True + else: + time.sleep(0.1) + raise Exception( + 'Timeout waiting for {}'.format(condition_function.__name__) + ) \ No newline at end of file diff --git a/homepage/functional_tests/test_login.py b/homepage/functional_tests/test_login.py new file mode 100644 index 0000000000000000000000000000000000000000..f3e5135d000c6faa791c283824ef628b807ba421 --- /dev/null +++ b/homepage/functional_tests/test_login.py @@ -0,0 +1,48 @@ +from django.core import mail +from selenium.webdriver.common.keys import Keys +import re + +from .base import FunctionalTest + +TEST_EMAIL = 'dhanar.santika@google.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) \ No newline at end of file diff --git a/homepage/homepage/settings.py b/homepage/homepage/settings.py index a1c6a67c4e9ce4671d82f4a4fa9612fbe66e580c..9f95c4e26ed02d7da017ffac8371686f5e6cd579 100644 --- a/homepage/homepage/settings.py +++ b/homepage/homepage/settings.py @@ -33,15 +33,38 @@ ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', + #'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'todo', + 'accounts', ] +AUTH_USER_MODEL = 'accounts.ListUser' +AUTHENTICATION_BACKENDS = [ + 'accounts.authentication.PasswordlessAuthenticationBackend', +] + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + }, + }, + 'root': {'level': 'INFO'}, +} + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -127,4 +150,10 @@ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.9/howto/static-files/ STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static') -STATIC_URL = '/static/' \ No newline at end of file +STATIC_URL = '/static/' + +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_HOST_USER = 'dhanar.santika@gmail.com' +EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_PASSWORD') +EMAIL_PORT = 587 +EMAIL_USE_TLS = True \ No newline at end of file diff --git a/homepage/homepage/urls.py b/homepage/homepage/urls.py index eb85f594464d76167f3daba8732f5f9a5e1ed679..bfd42cfe6d23af989e8f40d8d511021cc1fd42c3 100644 --- a/homepage/homepage/urls.py +++ b/homepage/homepage/urls.py @@ -14,12 +14,15 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include from home.views import index from todo.views import todo +from django.views.generic import RedirectView urlpatterns = [ - path('admin/', admin.site.urls), - path("", index, name="index"), + #path('admin/', admin.site.urls), + #path("", index, name="index"), + path("", RedirectView.as_view(url="todo/", permanent="true"), name="index"), path("todo/", todo, name="todo"), + path("accounts/", include('accounts.urls')), ] diff --git a/homepage/todo/templates/base.html b/homepage/todo/templates/base.html index 489ab02e4c5504dec35f0c12b519ab73a1442323..ea2b13e8b3cb03694b289551cec21bd18262f8c4 100644 --- a/homepage/todo/templates/base.html +++ b/homepage/todo/templates/base.html @@ -12,22 +12,20 @@ <body> <div class="container"> - - <div class="row"> - <div class="col-md-6 col-md-offset-3 jumbotron"> - <div class="text-center"> - <h1>{% block header_text %}{% endblock %}</h1> - <form method="POST" action="{% block form_action %}{% endblock %}"> - <input name="item_text" id="id_new_item" - class="form-control input-lg" - placeholder="Enter a to-do item" /> - {% csrf_token %} - </form> - </div> - </div> - </div> - - <div class="row"> + + <div class="navbar"> + {% if user.is_authenticated %} + <p>Logged in as {{ user.email }}</p> + <p><a id="id_logout" href="{% url 'logout' %}">Log out</a></p> + {% else %} + <form method="POST" action ="{% url 'send_login_email' %}"> + Enter email to log in: <input name="email" type="text" /> + {% csrf_token %} + </form> + {% endif %} + </div> + + <div class="row"> <div class="col-md-6 col-md-offset-3"> {% block table %} {% endblock %}