learning-website-django1-10 Exercise 2: Optional Login With a Limit on Wrong Passwords ======================================================================================= Visitors never need an account. A login only lets a site remember which chapters you have finished. There is no public sign-up: the owner makes accounts in the admin, so there is no registration form to attack or to fill with spam. Each site has its own login. A session cookie without a Domain belongs to one host, so being logged in on languages.osztromok.com does not log you in on systems.osztromok.com. (To share a login you would set SESSION_COOKIE_DOMAIN=".osztromok.com", which also shares the cookie with every other subdomain you will ever create; this project keeps them separate.) Settings (base.py): SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days LOGIN_URL = "/accounts/login/" LOGIN_MAX_FAILURES = 5 LOGIN_LOCKOUT_SECONDS = 15 * 60 Django's own defaults already make the cookie HttpOnly and SameSite=Lax; production adds Secure. ?next= is checked by Django: only an address on the SAME host is followed. Save as apps/accounts/views.py: import hashlib from django.conf import settings from django.contrib.auth import logout from django.contrib.auth.views import LoginView from django.core.cache import cache from django.shortcuts import redirect from django.views.decorators.http import require_POST def _failure_key(username): """One counter per user NAME, whether or not that user exists, so the answer never reveals which names are real.""" return "login-failures:" + hashlib.sha256(username.strip().lower().encode()).hexdigest()[:32] class SiteLoginView(LoginView): """Django's login, with a limit on wrong passwords. Each site has its own login (a cookie is not shared between subdomains), so this view is mounted on every site. ?next= is checked by Django: only an address on the same host is followed, anything else goes to /.""" template_name = "accounts/login.html" redirect_authenticated_user = True extra_context = {"breadcrumbs": [], "active_folder": None} def post(self, request, *args, **kwargs): name = request.POST.get("username", "")[:150] if cache.get(_failure_key(name), 0) >= settings.LOGIN_MAX_FAILURES: form = self.get_form() form.add_error(None, "Too many wrong passwords for that name. Try again in a few minutes.") return self.render_to_response(self.get_context_data(form=form), status=429) return super().post(request, *args, **kwargs) def form_valid(self, form): cache.delete(_failure_key(form.cleaned_data["username"])) return super().form_valid(form) def form_invalid(self, form): key = _failure_key(self.request.POST.get("username", "")[:150]) cache.add(key, 0, settings.LOGIN_LOCKOUT_SECONDS) try: cache.incr(key) except ValueError: # the counter expired between add and incr cache.set(key, 1, settings.LOGIN_LOCKOUT_SECONDS) return super().form_invalid(form) @require_POST def logout_view(request): """Logging out changes state, so it is a POST (with the CSRF token): a link or an cannot do it.""" logout(request) return redirect("/") Save as apps/accounts/templates/accounts/login.html: {% extends "theme/base.html" %} {% block title %}Log in{% endblock %} {% block content %}

Log in

Logging in is optional. It only lets this site remember which chapters you have finished.

{% csrf_token %} {% if form.non_field_errors %}{% endif %}
{% endblock %} How the limit works, and what it costs: - The count is kept per user NAME (a hash of it), whether or not that name exists, so the reply never tells an attacker which names are real. - After 5 wrong passwords the name answers 429 for 15 minutes, EVEN to the right password. The cost: someone who knows your user name can lock you out for 15 minutes. For a personal site that is acceptable; for a public one you would count per address too. - The count lives in Django's cache. The default cache is per process, so with several Gunicorn workers each keeps its own count and the real limit is up to 5 per worker. A shared cache (Redis or the database cache) makes it exact. This was not tested here. - Logging out is a POST with the CSRF token, so a link or an image cannot log you out. Save as tests/test_accounts.py: from django.contrib.auth import get_user_model from django.core.cache import cache from django.test import Client, TestCase, override_settings from apps.content.models import Course, Page from apps.progress.models import PageProgress User = get_user_model() PASSWORD = "correct-horse-battery-staple-9" # a throwaway used only inside the test database HOST = "languages.localhost" def make_page(path="hungary/x/a.html", site="languages", course=None, chapter_no=None): return Page.objects.create(path=path, site=site, kind="lesson", title="A", fragment="

body

", course=course, chapter_no=chapter_no) class LoginTests(TestCase): def setUp(self): cache.clear() self.user = User.objects.create_user("philip", password=PASSWORD) self.client = Client(HTTP_HOST=HOST) def test_every_page_offers_a_log_in_link_to_anonymous_visitors(self): make_page() html = self.client.get("/hungary/x/a/").content.decode() self.assertIn('href="/accounts/login/?next=/hungary/x/a/"', html) self.assertNotIn("Mark as finished", html) def test_a_right_password_logs_in_and_goes_to_the_page_asked_for(self): response = self.client.post("/accounts/login/", {"username": "philip", "password": PASSWORD, "next": "/hungary/x/a/"}) self.assertRedirects(response, "/hungary/x/a/", fetch_redirect_response=False) self.assertIn("_auth_user_id", self.client.session) def test_a_wrong_password_does_not(self): response = self.client.post("/accounts/login/", {"username": "philip", "password": "nope"}) self.assertEqual(response.status_code, 200) self.assertNotIn("_auth_user_id", self.client.session) def test_next_cannot_send_you_to_another_site(self): for target in ("https://evil.example/", "//evil.example/", "http://systems.localhost/"): client = Client(HTTP_HOST=HOST) response = client.post("/accounts/login/", {"username": "philip", "password": PASSWORD, "next": target}) self.assertEqual(response["Location"], "/", target) # the default, not the outside address def test_the_login_is_per_site(self): response = self.client.post("/accounts/login/", {"username": "philip", "password": PASSWORD}) self.assertEqual(response.cookies["sessionid"]["domain"], "") # no Domain: a browser sends it to this host only # (the test client has one cookie jar for every host, so a fresh client stands in for the other site) other = Client(HTTP_HOST="systems.localhost").get("/").content.decode() self.assertIn("Log in", other) def test_the_session_cookie_is_httponly_and_samesite(self): response = self.client.post("/accounts/login/", {"username": "philip", "password": PASSWORD}) cookie = response.cookies["sessionid"] self.assertTrue(cookie["httponly"]) self.assertEqual(cookie["samesite"], "Lax") self.assertEqual(cookie["domain"], "") # host-only @override_settings(LOGIN_MAX_FAILURES=3) def test_repeated_wrong_passwords_lock_the_name_even_for_the_right_one(self): for _ in range(3): self.assertEqual(self.client.post("/accounts/login/", {"username": "philip", "password": "no"}).status_code, 200) locked = self.client.post("/accounts/login/", {"username": "philip", "password": PASSWORD}) self.assertEqual(locked.status_code, 429) self.assertNotIn("_auth_user_id", self.client.session) @override_settings(LOGIN_MAX_FAILURES=3) def test_a_name_that_does_not_exist_is_limited_in_the_same_way(self): for _ in range(3): self.client.post("/accounts/login/", {"username": "nobody", "password": "no"}) self.assertEqual(self.client.post("/accounts/login/", {"username": "nobody", "password": "no"}).status_code, 429) @override_settings(LOGIN_MAX_FAILURES=3) def test_another_name_is_not_locked(self): for _ in range(3): self.client.post("/accounts/login/", {"username": "philip", "password": "no"}) User.objects.create_user("guest", password=PASSWORD) self.assertEqual(self.client.post("/accounts/login/", {"username": "guest", "password": PASSWORD}).status_code, 302) def test_a_good_login_clears_the_failures(self): for _ in range(2): self.client.post("/accounts/login/", {"username": "philip", "password": "no"}) self.client.post("/accounts/login/", {"username": "philip", "password": PASSWORD}) self.client.post("/accounts/logout/") for _ in range(3): self.client.post("/accounts/login/", {"username": "philip", "password": "no"}) self.assertEqual(self.client.post("/accounts/login/", {"username": "philip", "password": PASSWORD}).status_code, 302) def test_logging_out_needs_a_post(self): self.client.post("/accounts/login/", {"username": "philip", "password": PASSWORD}) self.assertEqual(self.client.get("/accounts/logout/").status_code, 405) self.assertIn("_auth_user_id", self.client.session) self.client.post("/accounts/logout/") self.assertNotIn("_auth_user_id", self.client.session) def test_the_login_form_is_protected_by_csrf(self): strict = Client(HTTP_HOST=HOST, enforce_csrf_checks=True) response = strict.post("/accounts/login/", {"username": "philip", "password": PASSWORD}) self.assertEqual(response.status_code, 403) class ProgressTests(TestCase): def setUp(self): self.user = User.objects.create_user("philip", password=PASSWORD) self.course = Course.objects.create(site="languages", folder="hungary/x", name="Hungarian X") self.pages = [make_page(f"hungary/x/c{n}.html", course=self.course, chapter_no=n) for n in (1, 2, 3, 4)] self.other = make_page("linux/y/z.html", site="systems") self.client = Client(HTTP_HOST=HOST) self.client.force_login(self.user) def mark(self, path, done="1", client=None): return (client or self.client).post("/progress/mark/", {"path": path, "done": done}) def test_marking_a_page_finished_shows_on_the_page_and_can_be_undone(self): self.assertRedirects(self.mark("hungary/x/c1.html"), "/hungary/x/c1/", fetch_redirect_response=False) self.assertIn("You have finished this page", self.client.get("/hungary/x/c1/").content.decode()) self.mark("hungary/x/c1.html", done="0") self.assertIn("Mark as finished", self.client.get("/hungary/x/c1/").content.decode()) self.assertEqual(PageProgress.objects.count(), 0) def test_marking_twice_is_one_row(self): self.mark("hungary/x/c1.html") self.mark("hungary/x/c1.html") self.assertEqual(PageProgress.objects.count(), 1) def test_a_page_of_another_site_cannot_be_marked(self): self.assertEqual(self.mark("linux/y/z.html").status_code, 404) self.assertEqual(self.mark("nothing/here.html").status_code, 404) self.assertEqual(PageProgress.objects.count(), 0) def test_marking_needs_a_login_and_a_post(self): anonymous = Client(HTTP_HOST=HOST) response = self.mark("hungary/x/c1.html", client=anonymous) self.assertEqual(response.status_code, 302) self.assertTrue(response["Location"].startswith("/accounts/login/")) self.assertEqual(PageProgress.objects.count(), 0) self.assertEqual(self.client.get("/progress/mark/").status_code, 405) def test_marking_needs_the_csrf_token(self): strict = Client(HTTP_HOST=HOST, enforce_csrf_checks=True) strict.force_login(self.user) self.assertEqual(self.mark("hungary/x/c1.html", client=strict).status_code, 403) def test_one_user_cannot_see_or_change_anothers_progress(self): other_user = User.objects.create_user("guest", password=PASSWORD) PageProgress.objects.create(user=other_user, path="hungary/x/c2.html") self.assertIn("Mark as finished", self.client.get("/hungary/x/c2/").content.decode()) self.mark("hungary/x/c2.html", done="0") # deletes only my (non-existent) row self.assertEqual(PageProgress.objects.filter(user=other_user).count(), 1) def test_the_overview_counts_chapters_per_course(self): self.mark("hungary/x/c1.html") self.mark("hungary/x/c3.html") html = self.client.get("/progress/").content.decode() self.assertIn("2 chapters finished", html) self.assertIn("2 of 4", html) self.assertIn('value="2"', html) def test_the_overview_needs_a_login(self): response = Client(HTTP_HOST=HOST).get("/progress/") self.assertEqual(response.status_code, 302) def test_progress_survives_the_page_being_deleted_and_recreated(self): self.mark("hungary/x/c1.html") self.pages[0].delete() make_page("hungary/x/c1.html", course=self.course, chapter_no=1) # what an import may do self.assertIn("You have finished this page", self.client.get("/hungary/x/c1/").content.decode()) def test_deleting_a_user_deletes_their_progress(self): self.mark("hungary/x/c1.html") self.user.delete() self.assertEqual(PageProgress.objects.count(), 0) class AdminTests(TestCase): def setUp(self): self.boss = User.objects.create_superuser("boss", password=PASSWORD) make_page() self.admin = Client(HTTP_HOST="admin.localhost") def test_the_admin_is_only_on_its_own_host(self): for host in ("languages.localhost", "systems.localhost"): self.assertEqual(Client(HTTP_HOST=host).get("/admin/").status_code, 404) self.assertEqual(Client(HTTP_HOST=host).get("/").status_code, 200) def test_the_admin_host_asks_for_a_login_and_shows_no_content_site(self): response = self.admin.get("/") self.assertEqual(response.status_code, 302) self.assertIn("/login/", response["Location"]) self.admin.force_login(self.boss) self.assertEqual(self.admin.get("/hungary/x/a/").status_code, 404) # content pages are not served here def test_an_ordinary_user_cannot_use_it(self): User.objects.create_user("reader", password=PASSWORD) self.admin.login(username="reader", password=PASSWORD) self.assertEqual(self.admin.get("/content/page/").status_code, 302) def test_a_staff_user_without_permissions_sees_nothing(self): User.objects.create_user("staff", password=PASSWORD, is_staff=True) self.admin.login(username="staff", password=PASSWORD) self.assertEqual(self.admin.get("/content/page/").status_code, 403) def test_the_owner_can_look_but_not_edit_pages(self): self.admin.force_login(self.boss) page = Page.objects.get() listing = self.admin.get("/content/page/") self.assertEqual(listing.status_code, 200) self.assertNotIn("body

", listing.content.decode()) # the body is not loaded or shown self.assertEqual(self.admin.get("/content/page/add/").status_code, 403) detail = self.admin.get(f"/content/page/{page.pk}/change/") self.assertEqual(detail.status_code, 200) self.assertNotIn("