Follow-up to #225 / #255. #255 adds an opt-in rotate_session_id(request) for 0.3.8. A helper the developer has to remember is the weakest form of the fixation defence, and some of the stronger forms are breaking changes, so this collects them for 0.4.0.
Principle
Becoming authenticated happens through one explicit API, and that API always issues a new session ID. There is no other way to attach a user through the library, so the library's own identity (session.user, get_session, UserSessionDep) cannot be fixated.
Limit: an application that decides "logged in" from its own request.session keys (request.session.get("user_id"), the usual Starlette pattern) is outside what the library can see. The docs must say this up front: use the library's identity, or rotate yourself.
Proposal
- Explicit
login() / logout()
await login(request, user) (exact shape to be decided: request-level function, or a method on the request's session) attaches the user and always rotates the ID. The middleware sends the new token through the request's transport.
logout() deletes the record. request.session.clear() keeps meaning logout, for compatibility.
Session.user becomes read-only outside these calls. Code that sets it directly today breaks: this is the breaking part.
rotate_session_id() stays for privilege changes without a new user (or becomes elevate()).
- A login starts a new session instead of promoting the anonymous one
login() creates a fresh record, copies only the data the caller asks to keep (keep=["cart"], or all by default: to be decided), and deletes the old one.
- Nothing an attacker put into a planted session is carried into the authenticated one unless the application chose to keep it.
- Short grace period after rotation
- Cookies that are hard to plant, by default
- Default
cookie_name becomes __Host-session with Secure. Browsers refuse __Host- cookies set from a subdomain or with a Domain attribute, which removes the usual fixation vector.
- Local development over plain http needs an explicit opt-out, and
SessionConfig should reject contradictory settings (a __Host- name with cookie_domain, a path other than /, or no Secure).
- Changing the default name logs every existing user out once; the changelog must say so.
Optional, can be split out:
Differences from Starlette's SessionMiddleware after this change
The dict API stays compatible: request.session still reads and writes the same way. What changes is the login semantics: writing a key no longer means "logged in" as far as the library is concerned. The migration guide needs a short table:
- where data lives;
- login and logout;
- revocation;
- cookie defaults;
- size limit;
- the need for a backend.
Related 0.4.0 items to ship together
One migration section in the docs covering all of them is easier on users than several separate ones.
Follow-up to #225 / #255. #255 adds an opt-in
rotate_session_id(request)for 0.3.8. A helper the developer has to remember is the weakest form of the fixation defence, and some of the stronger forms are breaking changes, so this collects them for 0.4.0.Principle
Becoming authenticated happens through one explicit API, and that API always issues a new session ID. There is no other way to attach a user through the library, so the library's own identity (
session.user,get_session,UserSessionDep) cannot be fixated.Limit: an application that decides "logged in" from its own
request.sessionkeys (request.session.get("user_id"), the usual Starlette pattern) is outside what the library can see. The docs must say this up front: use the library's identity, or rotate yourself.Proposal
login()/logout()await login(request, user)(exact shape to be decided: request-level function, or a method on the request's session) attaches the user and always rotates the ID. The middleware sends the new token through the request's transport.logout()deletes the record.request.session.clear()keeps meaning logout, for compatibility.Session.userbecomes read-only outside these calls. Code that sets it directly today breaks: this is the breaking part.rotate_session_id()stays for privilege changes without a new user (or becomeselevate()).login()creates a fresh record, copies only the data the caller asks to keep (keep=["cart"], or all by default: to be decided), and deletes the old one.cookie_namebecomes__Host-sessionwithSecure. Browsers refuse__Host-cookies set from a subdomain or with aDomainattribute, which removes the usual fixation vector.SessionConfigshould reject contradictory settings (a__Host-name withcookie_domain, a path other than/, or noSecure).Optional, can be split out:
Differences from Starlette's
SessionMiddlewareafter this changeThe dict API stays compatible:
request.sessionstill reads and writes the same way. What changes is the login semantics: writing a key no longer means "logged in" as far as the library is concerned. The migration guide needs a short table:Related 0.4.0 items to ship together
SessionMiddleware.UserSessionDeprequire a session with a user #127 makesUserSessionDeprequire a user, which fitslogin()being the only way to get one.One migration section in the docs covering all of them is easier on users than several separate ones.