State and Identity · practice
Keep a Session on the Server
A session scenario:
python pl_http.py session
It sends four requests: GET /me, then POST /login, then GET /me again with the cookie copied from the login reply’s set-cookie line, if there is one, and last GET /me with cookie: session=not-a-real-id. Today every reply is HTTP/1.0 404 Not Found with {"error": "nothing is at this path"}, because neither path exists yet.
Look at the POST /login the helper sends:
client -> server b'POST /login HTTP/1.1\r\nHost: books.test\r\nContent-Length: 0\r\n\r\n' (61 bytes)
It has no body, and still says Content-Length: 0. That is the rule from Chapter 5, Lesson 1: your read_body answers any POST without a length with 411 before handle even looks at the path, so a POST with nothing to send says so.
Keep the facts, hand out a key
The tempting design puts the user in the cookie: set-cookie: user=guest. It works until a client sends cookie: user=ana instead. A cookie is text the client stores and sends back, and the client can change it at will, so nothing in it can be trusted as a fact.
So the facts stay on the server. When someone logs in, the server makes up a new random id, stores who logged in under that id in state["sessions"], and hands the client only the id in a cookie. When a request comes back with that cookie, the server looks the id up:
client: cookie: session=K7f...Qw
|
v
server: state["sessions"]
"K7f...Qw": {"user": "ana"}
"p2Z...8c": {"user": "ben"}
The id points at one row, and the row says who the user is. Changing the cookie gets a client nothing unless it guesses another row’s id. That record on the server is a session, and the id is its session id. sample_state() now returns an empty "sessions"
Where does this server keep who is logged in?
An id nobody can guess
While a session lives, its id works like a password: anyone who sends cookie: session=<that id> is that user to the server. So the id has to be impossible to guess. Python’s secrets
secrets.token_urlsafe(16) takes 16 random bytes from the -, and _. Sixteen bytes is 128 random bits, far too many values to try one by one. The random module you may know from games and simulations is the wrong tool here: it is built to be fast and repeatable, not unpredictable. Use random for simulations and secrets for anything an attacker must not guess.
Why not number sessions 1, 2, 3?
start_session(state, user) does that work: one call to secrets.token_urlsafe(16), written through the module as secrets.token_urlsafe, then {"user": user} stored under the new id in state["sessions"], and the id returned.
Write and read the cookie
session_cookie(session_id, *, max_age=SESSION_AGE) returns the set-cookie header: session=<id>; HttpOnly; Max-Age=<max_age>; Path=/. You can build it with http.cookies.SimpleCookie, as in Lesson 1, setting httponly, path, and max-age and returning OutputString(), or with an token_urlsafe needs no quoting, so both ways write the same text. SESSION_AGE is 3600, an hour, and the keyword lets Lesson 7 pass 0.
session_user(request, state) reads it back. request["headers"].get("cookie", []) is a cookie values, usually one. For each value, http.cookies.SimpleCookie(value).get("session") gives the session cookie, or None when the line has none. Look its .value up in state["sessions"] with .get, and return the stored user, or None when no value names a live session.
What does this print?
import http.cookies
c = http.cookies.SimpleCookie("theme=dark; session=abc")
print(c["session"].value, len(c))
SimpleCookie has one sharp edge. If one piece of a Cookie line is malformed, it returns no cookies at all, silently: session=abc; bad cookie here; x=1 gives an empty result. A broken cookie from some other part of a site would then hide the session. A hand-written parser could be more forgiving; this lesson does not ask for one.
What /me answers
GET /me always answers 200: {"user": "guest"} for a live session, {"user": null} for no cookie, an unknown id, or an ended session. json.dumps writes Python’s None as null. Some APIs answer 401 here instead. A 401, though, must name a way to log in that the client can use at that WWW-Authenticate header, and a cookie session has no such scheme to name. So this server says “nobody” with a 200. Lesson 5 brings the first 401, on /login, where a scheme exists.
Your task
In reading_server.py:
Write
start_session(state, user).Write
session_cookie(session_id, *, max_age=SESSION_AGE).Write
session_user(request, state).In
handle, add a branch for the path/login: aPOSTstarts a session for"guest"and answers 200 with{"user": "guest"}and aset-cookieheader fromsession_cookie; any othergets not_allowed("POST"). Add a branch for/me:GETandHEADanswer 200 with{"user": ...}fromsession_user; any other method getsnot_allowed("GET, HEAD").
Until Lesson 5, every login is guest, whatever it sends: there are no credentials to check yet.
Run the scenario again:
python pl_http.py session
The first GET /me now answers {"user": null}. The login answers 200, and under its reply the helper prints the set-cookie line: session=, then 22 characters that change on every run, then HttpOnly; Max-Age=3600; Path=/. The third request carries that cookie and gets {"user": "guest"}; the made-up id gets {"user": null}.
The sessions live in the state dict, so they last as long as one run of the helper. Max-Age asks the client to forget the cookie after an hour, but nothing on this server ends a session by time: it reads no clock. Lesson 7 ends sessions on request.
Then press Check my work. The server now hands out a cookie and recognizes it, but the helper had to copy the cookie into the next request by hand. In the next lesson a client does that on its own.
Task
Keep a login session on the server in reading_server.py.
start_session(state, user)makes a new id withsecrets.token_urlsafe(16), written through the( import secretsis already at the top;secrets.token_hex(16)is accepted too, and so is any size of 16 bytes or more), stores{"user": user}under it instate["sessions"], and returns the id.session_cookie(session_id, *, max_age=SESSION_AGE)returns theset-cookiesession=<id>; HttpOnly; Max-Age=<max_age>; Path=/, with the attributes in any order and noSecure, as Lesson 1 explained. Build it withhttp.cookies.SimpleCookieor an. session_user(request, state)reads thesessionfrom the request’s cookieheader withhttp.cookies.SimpleCookieand returns the user of that session, orNonewhen there is no cookie, nosessioncookie, or no session under its value.In
handle:POST /loginstarts a session for"guest"and answers 200 with{"user": "guest"}and aset-cookieheader fromsession_cookie; any otheron /logingets 405 withallow: POST.GET /meandHEAD /meanswer 200 with{"user": name}, or{"user": null}without a live session; any other method gets 405 withallow: GET, HEAD. Every other route keeps its reply.The session id never appears in a reply body.
Run python pl_http.py session in the set-cookie line and the three /me replies.
Then press Check my work. With reading_server.secrets replaced by a stand-in that records the size you ask for and returns ids you have not seen, it checks that start_session stores the session under exactly that id and that the cookie and handle use it. It parses your cookie with http.cookies.SimpleCookie and checks the value, HttpOnly, Path=/, and Max-Age, including max_age=0. It calls handle with a state you have not seen: a login, /me with the cookie alone, beside another cookie in either order, with an unknown id, with no cookie, and with a cookie named sessionx, HEAD /me, and the 405s. Last, with the real secrets, it starts 40 sessions on one state and on fresh states and checks that every id is different and at least 22 characters long. Nothing from your terminal is graded.