State and Identity · practice
Basic Authentication
Your login still starts a session for anyone. Run the helper’s basic scenario to see how far that goes:
python pl_http.py basic
It sends three logins of its own, each with Content-Length: 0: one with no credentials, one for ana with a wrong password, and one with her right password, both taken from sample_state(). Then it calls your login_and_me with the right ones. Here is the wrong password going out:
client -> server b'POST /login HTTP/1.1\r\nHost: books.test\r\nAuthorization: Basic YW5hOm5vdCB0aGUgcGFzc3dvcmQ=\r\nContent-Length: 0\r\n\r\n' (112 bytes)
Today all four logins answer HTTP/1.0 200 OK with a fresh set-cookie, the wrong password included, and your client reads me: {'user': 'guest'}. This lesson makes the login check who is asking.
The header
That Authorization line is HTTP’s Basic scheme (RFC 7617). Its value is the scheme name, Basic, one space, and then base64 of the UTF-8 bytes of user:password. Decode YW5hOm5vdCB0aGUgcGFzc3dvcmQ= and you get ana:not the password. The scheme name is case-insensitive, so basic is the same scheme.
The colon separates the two parts, and the rules make a user name that contains a colon impossible. A password can contain colons, though: a:b:c is a valid one. So split at the first colon only. str.partition(":") does exactly that, returning the part before the first colon, the colon, and everything after it. When there is no colon, the middle part is "".
What does this print?
print("ana:pa:ss".partition(":"))
Refuse with a challenge
A login with missing or wrong credentials gets 401 Unauthorized. The name is old and misleading: 401 means “I do not know who you are”, not “you may not”. A 401 must also tell the client how to prove who it is, in a WWW-Authenticate header (RFC 9110, section 15.5.2). For this server the challenge is the constant REALM, already in reading_server.py:
www-authenticate: Basic realm="reading list", charset="UTF-8"
Basic names the scheme. The realm is a name for the protected area, which a browser may show when it asks for a password, and charset="UTF-8" says the server decodes the credentials as UTF-8. unauthorized(challenge) builds that reply: a JSON error with status 401 and the www-authenticate header set to challenge.
POST /login arrives with a known user and a wrong password. Which reply?
Decode strictly, then compare
basic_credentials(request) turns the header into {"user": ..., "password": ...}, or returns None when there is nothing usable:
Take
request["headers"].get("authorization", []). Anything but exactly one value givesNone.Strip the value and partition it at the first space. The first part, lowercased, must be
basic. Strip the rest, so extra spaces after the scheme do no harm.Decode the rest with
base64.b64decode(..., validate=True), then.decode("utf-8"), insidetry. Lesson 4 showed that a bad character raisesbinascii.Errorand non-UTF-8 bytes raiseUnicodeDecodeError; both are kinds ofValueError, so oneexcept ValueError:returningNonecovers both, as in Chapter 5’sbody_error.With no colon in the text, return
None. Otherwise partition at the first colon.
The /login branch then compares: when basic_credentials returns None, or state["users"].get(user) is not the password, answer unauthorized(REALM), with no cookie and no session. Otherwise start a session for that user and answer 200 with {"user": user} and the cookie.
This server keeps passwords as plain strings and compares them with ==, to keep the lesson about HTTP. A real service stores only a slow, salted hash of each password, and compares secrets with hmac.compare_digest, so the time a comparison takes reveals nothing about how much of a guess was right. Password storage is outside this course.
Say what failed, not the secret
Every failure gets the same body, such as {"error": "credentials are missing or wrong"}. Two habits matter. A reply never repeats a credential, because replies end up in logs and on screens. And the message does not say whether the user name exists, because “wrong password” for one name and “unknown user” for another tells a stranger which names to try.
Which 401 body is safe?
Only inside HTTPS
Lesson 4 showed that base64 hides nothing. A Basic header is the password, readable by anyone who sees the request on its way. Basic credentials are only acceptable inside HTTPS, where TLS encrypts the whole request, headers included; so are bearer tokens and session cookies. This server speaks plain HTTP on this machine’s loopback address, which nobody else can reach, and that is the course’s learning exception. Chapter 9’s last lesson shows the TLS wrapper.
The client half
In wire.py, basic_header(user, password) returns the header value: "Basic " followed by base64 of f"{user}:{password}" encoded as UTF-8, turned back into text with .decode("ascii"). Then login_and_me sends it: when user is not None, pass headers={"Authorization": basic_header(user, password)} to the login’s urllib.request.Request. Send it on the login only. After that, the cookie says who the client is.
Chapter 6 left one question about credentials and redirects open. When urllib’s opener follows a redirect, it copies every header of the request onto the next one except content-length and content-type, so a Basic Authorization header goes along to whatever host the redirect names. The cookie jar is more careful: it decides the Cookie line again for every request, and sends only the cookies that match the new host and path. This server only redirects to itself, so nothing leaks here, but a client that follows redirects to any host while sending a Basic header hands the password to whoever the redirect names.
In reading_server.py, write unauthorized and basic_credentials and rewrite the /login branch as above. In wire.py, write basic_header and send it from login_and_me. Then run the scenario again:
python pl_http.py basic
The first two logins now answer HTTP/1.0 401 Unauthorized, and under each the helper prints:
www-authenticate: Basic realm="reading list", charset="UTF-8"
The right password gets 200 and a set-cookie line, and your client reads me: {'user': 'ana'}. The session scenario from Lesson 2, which sends no credentials, now gets a 401 for its login too.
Then press Check my work. Logins now mean something, but POST, PUT, and DELETE on the books still accept anyone. The next lesson protects them.
Task
Check Basic credentials on login in reading_server.py, and send them from wire.py.
In reading_server.py:
unauthorized(challenge)returns a 401 JSON error whosewww-authenticateheader ischallenge.basic_credentials(request)returns{"user": ..., "password": ...}from anauthorizationheader, orNone. ReturnNoneunless there is exactly oneauthorizationvalue whose scheme isbasicin any case. Strip the text after the scheme, decode it withbase64.b64decode(..., validate=True)and then as UTF-8, and returnNonewhen either step raisesValueErroror the text has no colon. Split at the first colon only: the password may contain more.POST /login: whenbasic_credentialsreturnsNoneor the password is notstate["users"]'s password for that user, answerunauthorized(REALM)with noset-cookieand no new session. Otherwise start a session for that user and answer 200 with{"user": user}and the cookie from Lesson 2.No reply repeats a password or a base64 credential. Word the error any way you like.
In wire.py:
basic_header(user, password)returns"Basic "followed by the base64 off"{user}:{password}"encoded as UTF-8, as text.login_and_mesendsAuthorization: basic_header(user, password)on the login request whenuseris notNone, and noAuthorizationheader otherwise. The/merequest carries none.
Run python pl_http.py basic in the terminal: two refused logins with their www-authenticate line, then a session for ana.
Then press Check my work. It calls basic_credentials with headers it builds for users and passwords you have not seen, including non-ASCII ones, a password with colons, and malformed headers, and calls handle with a state of its own: right and wrong passwords, unknown users, and missing or malformed headers. It checks each status, the www-authenticate scheme and realm, the cookie, the sessions, and that no reply repeats a credential. It checks basic_header against a value worked out in advance, and runs login_and_me with stand-ins for urllib, as in Lesson 3, with right and wrong credentials. Nothing from your terminal is graded.