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.
Who Is Allowed to Do What
| Who | Can | Cannot |
|---|---|---|
| Anyone | Read every page, search, see sitemaps | Anything that changes data |
| Logged-in reader | Mark chapters finished; see their own progress | See anyone else's progress; use the admin |
| Owner (superuser) | Use the admin to look at pages and courses, and to make users | Edit 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:
- It has its own host.
admin.osztromok.com(andadmin.localhostin 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. - 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.
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 request | Result |
|---|---|
| All pages | 200, 4,408 pages, 130 ms, 36 KB |
| Filter: languages site, course chapters | 200, 246 results, 75 ms |
| Page detail | 200, no text box for the body |
| An attempted change to a page | 403 (refused) |
/admin/ on the languages site | 404 |
| The admin host with no login | 302 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 needSESSION_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 noDomaininstead 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 withhttps://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:
- 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
| Attempt | Result |
|---|---|
| Mark a page without logging in | 302 to the login page; nothing saved |
| Mark by GET instead of POST | 405 |
| Mark without the CSRF token | 403 |
| Mark a page of another site, or a path that does not exist | 404 |
| Mark the same page twice | One row (a unique constraint) |
| Undo for a page that another user finished | That user's row is untouched |
| View the overview without logging in | 302 to the login page |
| Delete a user | Their 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.
Hands-On Exercises
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 solutionAdd 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.
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 solutionChapter 10 Quick Reference
- The admin lives on
admin.osztromok.comwith 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 needsSESSION_COOKIE_DOMAINand 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