Admin, Accounts and Progress

Learning Website with Django

Chapter 10 ยท Admin, Accounts & Progress

Until now every visitor is anonymous and nothing on the site can be changed through the site itself. This chapter adds three small things: an admin for looking at what was imported, an optional login, and a button that remembers which chapters you have finished. The interesting part is not the code, which is short, but the decisions about what not to allow.

Run for real, on Django 6.1 and SQLite
The project has 245 tests (all passing). The admin and the progress pages were used on the real database of 4,408 pages, with a temporary user (random password, never printed, deleted afterwards). The buttons were not clicked in a real browser, and behaviour with several worker processes was not tested.

Who Is Allowed to Do What

WhoCanCannot
AnyoneRead every page, search, see sitemapsAnything that changes data
Logged-in readerMark chapters finished; see their own progressSee anyone else's progress; use the admin
Owner (superuser)Use the admin to look at pages and courses, and to make usersEdit or delete a page (on purpose)

The Admin: Its Own Host, Read-Only

Django's admin is the quickest way to see what is in the database, but two decisions matter more than the setup:

  1. It has its own host. admin.osztromok.com (and admin.localhost in development) has its own URL configuration containing the admin and nothing else. The content sites do not contain the admin at all, so /admin/ is a plain 404 on every one of them: there is nothing to find or guess at. The middleware from Chapter 2 gained one branch: if the host is the admin host, use the admin's URL configuration.
  2. It is read-only. The files are the source of truth and the importer overwrites the database from them, so a title edited in the admin would silently vanish at the next import. A form that loses your work without telling you is worse than no form, so the add, change and delete permissions are switched off for pages, courses and progress.
class ReadOnlyAdmin(admin.ModelAdmin): def has_add_permission(self, request): return False def has_change_permission(self, request, obj=None): return False def has_delete_permission(self, request, obj=None): return False

The page body is left out of the list (the query defers it) and out of the detail form, so listing 4,408 pages stays cheap. On the real database:

Admin requestResult
All pages200, 4,408 pages, 130 ms, 36 KB
Filter: languages site, course chapters200, 246 results, 75 ms
Page detail200, no text box for the body
An attempted change to a page403 (refused)
/admin/ on the languages site404
The admin host with no login302 to the login page

Times come from Django's test client, so they leave out the network. The tests also check that an ordinary user and a staff user with no permissions are turned away, and that a posted change and a posted delete leave the page unchanged.

Accounts: Optional, and Per Site

Nobody needs an account to read anything. A login only lets a site remember what you have finished. There is no public sign-up: the owner makes users in the admin, so there is no registration form to attack or to fill with spam.

  • One login per site. The session cookie has no Domain, so a browser sends it to the host that set it and no other. Logging in on the languages site does not log you in on the systems site. Sharing a login would need SESSION_COOKIE_DOMAIN=".osztromok.com", which also hands the cookie to every subdomain you create later; the project keeps them apart. (The test client has one cookie jar for all hosts, so the test checks the cookie has no Domain instead of trying to see the separation.)
  • The cookie is HttpOnly and SameSite=Lax (Django's defaults), and Secure in production. A login lasts 30 days.
  • ?next= cannot send you elsewhere. Django follows it only when it points at the same host. Tested with https://evil.example/, //evil.example/ and another of the sites: each goes to /.
  • Logging out is a POST with the CSRF token, so a link or an image on another page cannot do it.

Limiting Wrong Passwords

Django does not limit login attempts by itself. After 5 wrong passwords for one user name, the login answers 429 for 15 minutes, even to the right password. The count is kept for the name as typed, whether or not that name exists, so the reply never reveals which names are real. Three costs are worth knowing:

What this limit does not do
  • Someone who knows your user name can lock you out for 15 minutes. That is acceptable for a personal site; a public one would also count per address.
  • The count lives in Django's cache, which by default is separate for each process. With several Gunicorn workers each keeps its own count, so the real limit is up to 5 per worker. A shared cache (Redis or the database cache) makes it exact. Not tested here.
  • Behind a proxy every request seems to come from the proxy's address, which is another reason the count is by name and not by address.

Progress

A logged-in reader sees a button at the bottom of every page: Mark as finished, or, once done, Mark as not finished. The /progress/ page lists each course on the site with how many chapters are finished. On the real content, five chapters of Hungarian Basic Conversation 3 marked finished gave “5 chapters finished” and “5 of 12” in 13 ms, and chapter 8 of the same course still showed the unfinished button.

Path, Not Foreign Key

A progress row stores the page's path (hungary/x/c1.html), not a link to the page row. The importer may delete and recreate a page, and a foreign key with the usual cascade would delete the reader's progress with it. With a path, a test deletes a page, recreates it and finds the progress still there. The cost is the other direction: if a file is renamed, its progress points at a path that no longer exists and counts for nothing. Renaming a chapter file is rare; losing progress on every re-import would not be.

The Safety Checks, Each With a Test

AttemptResult
Mark a page without logging in302 to the login page; nothing saved
Mark by GET instead of POST405
Mark without the CSRF token403
Mark a page of another site, or a path that does not exist404
Mark the same page twiceOne row (a unique constraint)
Undo for a page that another user finishedThat user's row is untouched
View the overview without logging in302 to the login page
Delete a userTheir progress goes too

The redirect after marking is built from the page that was found, not from anything the visitor sent, so there is nothing to turn into an open redirect. The cached menus are unaffected, because nothing user-specific is cached. The page itself now differs for each logged-in reader, so a page that shows the button must never be cached as a whole.

What was not verified
The buttons were not clicked in a real browser (the run used the test client, which sends the same requests); the login page was checked in headless Chrome. The lockout with several worker processes and the production cookie settings behind Apache were not tested. Nothing was run against PostgreSQL.

Hands-On Exercises

Exercise 1

Put the Django admin on its own host with its own URL configuration, make it read-only for pages, courses and progress, and keep the page body out of its lists. Test that an ordinary user, a staff user with no permissions and an attempted edit are all refused, and run it on the real database.

๐Ÿ“„ View solution
Exercise 2

Add an optional, per-site login with no public sign-up. Limit wrong passwords per user name, make logging out a POST, and test the ?next= attacks, the CSRF check and the lockout. State the limits of the lockout honestly.

๐Ÿ“„ View solution
Exercise 3

Add a “Mark as finished” button and a per-course progress page. Store the page by path so progress survives a re-import, and test the wrong site, wrong user, no login, no CSRF token, a deleted page and a deleted user.

๐Ÿ“„ View solution

Chapter 10 Quick Reference

  • The admin lives on admin.osztromok.com with its own URL configuration; /admin/ is a 404 on every content site
  • The admin is read-only: the files are the source of truth and the importer would overwrite an edit
  • No public sign-up: the owner makes users in the admin
  • One login per site: the session cookie has no Domain; a shared login needs SESSION_COOKIE_DOMAIN and shares the cookie with every subdomain
  • ?next= is followed only on the same host; log out is a POST with the CSRF token
  • 5 wrong passwords for a name give 429 for 15 minutes, counted by name; the cache is per process unless you use a shared one
  • Progress is stored by path, so a re-import keeps it; a renamed file loses it
  • Marking: POST, login and CSRF required; the page must be on this site; the redirect comes from the found page
  • Real run: admin list of 4,408 pages in 130 ms; progress page 13 ms; 245 tests pass
  • Not verified: real-browser clicks, several workers, production cookies behind Apache