Chapter 18 · Authentication, sessions and messages
18.8 The user of a request: login, logout and AuthenticationMiddleware
login writes a user into the session store and logout empties it; AuthenticationMiddleware puts on every request a lazy HttpRequest.user, which django reads back from the session at its first use, checking a hash of the user’s stored password against the one written at the login.
A password is checked once, by authenticate (Authenticating: authenticate and the backends). After that every request from the same browser has to know its user without the password, and has to stop knowing it when the user logs out, when someone else logs in in that browser, and when the user’s password changes. Django keeps the answer in three entries of the session: login writes them, get_user reads them, and logout throws them away with the rest of the session.
The three entries
login writes into the session (django/contrib/auth/__init__.py ).| entry | key in the session | holds |
|---|---|---|
SESSION_KEY |
key in the session "_auth_user_id" |
holds the user’s primary key, as a string |
BACKEND_ |
key in the session "_auth_ |
holds the dotted path of an authentication backend, ordinarily the one that found the user |
HASH_SESSION_KEY |
key in the session "_auth_ |
holds an HMAC of the user’s stored password string |
The primary key is written by the key field’s value_to_string and read back by _get_user_session_key through the field’s to_python: the comment there says the value in the session is always a string and has to be converted whenever it is read. The backend’s path is kept, in the documentation’s words, so that the same backend can fetch the user on a later request.
login
login(request, user, backend=None) works in this order:
- It computes the user’s hash with
AbstractBaseUser..get_session_auth_hash - It looks at what the session holds. A session without a user id keeps its data under a new key:
SessionBasemoves the data to a new key and deletes what was stored under the old (The session store:.cycle_key SessionBase). The documentation gives the reason: it mitigates session fixation. In that attack, someone who knows a valid session key gets a victim’s browser to send it; were the key kept through the login, the attacker, sending the same key, would be logged in as the victim. A session holding another user’s id, or this user’s with another hash, is flushed instead:SessionBaseempties it, deletes what was stored and drops the key, so that, as the comment says, another user’s session is not reused. A session that already holds this user with this hash is left as it is, key and all..flush - It settles the backend with
_get_backend_from_user: the one given tologin, else the pathauthenticateset on the user, else the entry ofAUTHENTICATION_BACKENDSif it has only one. With several and neither given it raisesValueError, and for a backend given as anything but a string,TypeError; both come after the second step has changed the session. - It writes the three entries.
_set_auth_userputs the user on the request:HttpRequest.userbecomes the user itself in place of the lazy object, andHttpRequesta coroutine function that returns it, each only if the request already had one..auser rotate_tokenreplaces the CSRF secret, so tokens made before the login stop matching (The CSRF secret and its token).- It sends
user_logged_inwith the request and the user.
Django connects one receiver to user_logged_in, update_last_login, which sets AbstractBaseUser to the current time and saves that field alone. AuthConfig.ready connects it only when the user model’s last_login is a field, a DeferredAttribute on the class; a custom user model that sets it to None gets no receiver.
The harbour office lets a visitor choose a port before signing in, and keeps the choice in the session:
# harbour/office/views.py, at "ports/<str:name>/" in harbour/urls.py
def port(request, name):
request.session["port"] = name
return HttpResponse("Your port is %s." % name)
One browser chose Valletta, then logged in as nora, as nora again, and as piet. After each response the session table held one row:
# a run of the example: keys and hashes cut, the backend's entry left out
GET /ports/Valletta/, anonymous
response: 200; "sessionid" set to kr4vnnvn...
row: kr4vnnvn... {'port': 'Valletta'}
POST /accounts/login/ as nora
response: 302 to /berths/; "sessionid" set to hv7um84a...
row: hv7um84a... {'port': 'Valletta', '_auth_user_id': '1', '_auth_user_hash': '000cc855...'}
POST /accounts/login/ as nora again
response: 302 to /berths/; "sessionid" set to hv7um84a...
row: hv7um84a... {'port': 'Valletta', '_auth_user_id': '1', '_auth_user_hash': '000cc855...'}
POST /accounts/login/ as piet
response: 302 to /berths/; "sessionid" set to vng29659...
row: vng29659... {'_auth_user_id': '3', '_auth_user_hash': '0c2af9a8...'}
Nora’s login moved the port to a new key; nora’s second login kept the key; piet’s flushed the session, port and all, and its row went with it.
alogin takes the same steps through the session’s asynchronous methods and sends the signal with Signal.asend.
logout
logout sends user_logged_out first; the comment gives the reason, that its receivers can still find out who logged out. The user it sends is the request’s, or None when that user is anonymous or the request has none. Then it flushes the session and puts an AnonymousUser on the request with _set_auth_user. The documentation’s reason for emptying the whole session: another person using the same browser should not reach the previous user’s session data, and whatever is meant to outlast the logout is to be written after it. alogout asks HttpRequest for the user and flushes with SessionBase.
AuthenticationMiddleware
AuthenticationMiddleware first checks that the request has a session, and without one raises ImproperlyConfigured, with a message that asks for SessionMiddleware before it in MIDDLEWARE. Then it puts two things on the request and looks nothing up. HttpRequest.user is a SimpleLazyObject over the middleware module’s get_user, which calls django once and keeps the answer on the request as "_cached_user". So the user is looked up at its first use, whatever uses it, and a request that never uses it never looks it up (Lazy objects: LazyObject and SimpleLazyObject).
HttpRequest is the module’s coroutine function auser, bound to the request, which awaits aget_user once and keeps the answer as "_acached_user", a cache of its own. aget_user takes the steps of django through SessionBase.aget, SessionBase and SessionBase, and asks the backend’s aget_user, which BaseBackend runs through sync_to_async and ModelBackend answers with an asynchronous query.
The class sets AuthenticationMiddleware to False, so under ASGI it runs synchronously (Sync and async in one chain).
Reading the user back: django.contrib.auth.get_user
django takes these steps, and answers with an AnonymousUser wherever one fails:
- It reads the user id and the backend’s path. A session missing either is anonymous.
- It goes on only if the path is still in
AUTHENTICATION_BACKENDS, makes that backend, and asks it for the user by the id.ModelBackendgives.get_user Nonefor a user who no longer exists, and for an inactive one, whichModelBackendrefuses..user_can_authenticate - It compares the hash in the session with the user’s hash now, by
constant_time_compare, unless the user object has noget_session_auth_hash. A session without a hash fails the comparison. - When they differ and the session’s hash matches one made with a key of
SECRET_KEY_FALLBACKS, the session is kept:cycle_keygives it a new key and the user’s hash under the current key is written in. Otherwise the session is flushed and the request is anonymous.
Only the fourth step flushes. A session that fails earlier is left as it was: in a run where nora was made inactive, or the backend of nora’s login dropped from the setting, the next request was sent to the login page and the session’s row and cookie stayed.
Two more browsers logged in as nora; then nora’s password was changed under the first, and the secret key under the second:
# a run of the example: nora signed in, in two more browsers
nora's password set and saved outside the browser
GET /berths/
response: 302 to /accounts/login/?next=/berths/; "sessionid" deleted
row: none
...
SECRET_KEY replaced, the old key in SECRET_KEY_FALLBACKS
GET /berths/
response: 200; "sessionid" set to truzdrm0...
row: truzdrm0... {'_auth_user_id': '1', '_auth_user_hash': 'ecb12b9e...'}
page: <p>Signed in as nora.</p>
nora's hash under the new key: ecb12b9e...
The session’s data, signed with the old key, still loaded because signing tries the fallbacks too (Secret keys and django).
The session hash
AbstractBaseUser is salted_hmac of the field AbstractBaseUser, the string the hasher stored and not the password, with the salt "django, the secret key and SHA-256, in hex. AbstractBaseUser yields the same HMAC under each key of SECRET_KEY_FALLBACKS. The documentation names the purpose: a user can log out every session by changing the password.
Since the hash is of the stored string, whatever rewrites the string ends every session holding a hash of the old one, whether the password changed or not. A hash upgraded at a login does: in a run that made nora’s string and logged nora in with PBKDF2PasswordHasher set within the process to 1,500,000, 6.1’s count, and then put it back to 1,800,000, nora’s login in a second browser hashed the password again, and the first browser’s next request was sent to the login page (Password hashing: make_password and the hashers).
update_session_auth_hash keeps the current session through a password change. It cycles the session key, and writes the new hash when the user is the request’s user; the documentation’s reason for the new key is that a stolen session cookie stops working. PasswordChangeView calls it after saving the form (The login and password views: LoginView, LogoutView and PasswordChangeView), and so does the admin’s view that changes a user’s password (The admin). aupdate_session_auth_hash is its asynchronous twin.