0%

State and Identity · practice

Keep a Session on the Server

A lets a client carry a reminder from one request to the next. This lesson gives your server something worth reminding it of: a login. First, see what the server does today with the requests a login needs. Run the helper’s 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" for them, beside the users and tokens that later lessons use.

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 makes values for exactly this:

Try it

secrets.token_urlsafe(16) takes 16 random bytes from the and writes them as 22 characters of letters, digits, -, 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 of 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 . The attribute order does not matter. An id from 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 of the request’s 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 , in a 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:

  1. Write start_session(state, user).

  2. Write session_cookie(session_id, *, max_age=SESSION_AGE).

  3. Write session_user(request, state).

  4. In handle, add a branch for the path /login: a POST starts a session for "guest" and answers 200 with {"user": "guest"} and a set-cookie header from session_cookie; any other gets not_allowed("POST"). Add a branch for /me: GET and HEAD answer 200 with {"user": ...} from session_user; any other method gets not_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 with secrets.token_urlsafe(16), written through the (import secrets is 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 in state["sessions"], and returns the id.

  • session_cookie(session_id, *, max_age=SESSION_AGE) returns the set-cookie session=<id>; HttpOnly; Max-Age=<max_age>; Path=/, with the attributes in any order and no Secure, as Lesson 1 explained. Build it with http.cookies.SimpleCookie or an .

  • session_user(request, state) reads the session from the request’s cookie header with http.cookies.SimpleCookie and returns the user of that session, or None when there is no cookie, no session cookie, or no session under its value.

  • In handle: POST /login starts a session for "guest" and answers 200 with {"user": "guest"} and a set-cookie header from session_cookie; any other on /login gets 405 with allow: POST. GET /me and HEAD /me answer 200 with {"user": name}, or {"user": null} without a live session; any other method gets 405 with allow: 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 and read 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.