Roles
Backstage Passes
At a concert, your wristband gets you through the gate and into the crowd. But the door behind the stage has its own guard, and the wristband is not enough there: you need a backstage pass. The crew has passes; fans do not.
PyWeb works the same way. Logging in gives a visitor a session cookie,
the wristband. SessionAuth, a middleware, reads it on every request
and puts the visitor in request.user. A role is a backstage pass:
a name such as "crew" or "admin" stored with the user. The
require(...) decorator is the guard at a door:
require()-- any logged-in visitor may come in.require("crew")-- only visitors holding the"crew"pass.- A stranger gets
401 Unauthorized. A browser (one that asks for HTML) is sent to the login page instead, with?next=saying where it was going. - A logged-in visitor without the pass gets
403 Forbidden.
Try It
from pynet.loop import run
from pyweb.app import App
from pyweb.auth import InMemoryUsers, SessionAuth, current_user, require
from pyweb.messages import empty
from pyweb.security import SessionSigner
from pyweb.testing import TestClient
users = InMemoryUsers()
ada = users.add("ada", "demo-password-not-real", roles=["crew"], rounds=1_000)
bob = users.add("bob", "another-demo-password", rounds=1_000)
auth = SessionAuth(SessionSigner("demo-key-not-real-0123456789abcdef"), users)
app = App()
app.middleware(auth)
@app.get("/stage")
@require()
def stage(request):
return {"welcome": current_user(request).name}
@app.get("/backstage")
@require("crew")
async def backstage(request):
return {"crew": current_user(request).name}
def wristband_for(user):
response = empty()
auth.login(response, user)
return response.headers.get("Set-Cookie").split(";")[0].split("=", 1)[1]
async def main():
stranger = TestClient(app)
print((await stranger.get("/stage")).status)
page = await stranger.get("/stage", headers={"Accept": "text/html"})
print(page.status, page.headers.get("Location"))
fan = TestClient(app, cookies={"session": wristband_for(bob)})
print((await fan.get("/stage")).json())
print((await fan.get("/backstage")).status)
roadie = TestClient(app, cookies={"session": wristband_for(ada)})
print((await roadie.get("/backstage")).json())
run(main())
Output:
The demo hands out wristbands directly; a real site does it in a login
handler with authenticate(users, throttle, name, password) and then
auth.login(response, user). The passwords and key are fake.
The Rules
- Put
requireunder the route decorator, so the App registers the guarded version. Put it on top by mistake and the App would already hold the unguarded handler -- a door with the guard standing in the wrong corridor -- so PyWeb stops you straight away withTypeError: put @require below the route decorator (closer to the function)instead of quietly leaving the page open to everyone. It works onasync defand plaindefhandlers, keeps the handler's name and path parameters, and stays a plaindeffor a plain handler, soblocking=Truestill works. - WebSocket doors too.
@app.websocket(...)routes can userequire.SessionAuthchecks them before the handshake, so a stranger gets a plain401(never a redirect) and the WebSocket is never opened. A cookie visitor must also be calling from your own site (theOrigincheck in CSRF Protection). - API tokens.
auth.api_token(user)makes a token for scripts to send asAuthorization: Bearer <token>. If that header is sent, it is the only thing looked at: a bad token means "stranger", even with a good cookie. - Broken wristbands. A tampered, expired or unknown cookie never causes an error page. The visitor is just a stranger.
- Logging out (
auth.logout(response)) asks the browser to forget the cookie. The old token itself keeps working until it expires (8 hours), because it is stamped, not stored. - Too many guesses.
authenticateuses aLoginThrottle: after 5 wrong passwords in 5 minutes the name is locked for 30 seconds and login answers429. - Going back. Before redirecting to a
?next=value after login, pass it throughsafe_next(...), which only allows paths on your own site.
Your Own Tokens
Some visitors are not people at a gate but robots, such as a build server. Think of a spare key you cut for the robot: it is not a wristband, but the guard still checks it against the key list.
Give SessionAuth a bearer_verifier: a small function that gets the
token text and returns the User it belongs to, or None if it does not
know the token. It is asked first; if it says None, PyWeb still tries
its own signed API tokens. Bearer requests never need a CSRF token, and
a bad one makes the visitor a stranger even with a good cookie. If your
function breaks, PyWeb logs the error type (never the token or message) and treats the visitor
as a stranger instead of showing a 500. The function runs on the event
loop, so keep it a quick in-memory lookup: no files, no network, no slow
password hashing.
from pynet.loop import run
from pyweb.app import App
from pyweb.auth import InMemoryUsers, SessionAuth, User, require
from pyweb.security import SessionSigner
from pyweb.testing import TestClient
SPARE_KEYS = {"robot-key-not-real": User("robot", "", frozenset({"viewer"}))}
auth = SessionAuth(
SessionSigner("demo-key-not-real-0123456789abcdef"),
InMemoryUsers(),
bearer_verifier=SPARE_KEYS.get,
)
app = App()
app.middleware(auth)
@app.get("/reports")
@require("viewer")
def reports(request):
return {"ok": True}
async def main():
client = TestClient(app)
good = await client.get("/reports", headers={"Authorization": "Bearer robot-key-not-real"})
bad = await client.get("/reports", headers={"Authorization": "Bearer guess"})
print(good.status, bad.status)
run(main())
What We Test
- Login sets an
HttpOnly,SameSite=Laxcookie; logout clears it. - Strangers get
401(JSON) or303to/login?next=%2Fjobs(HTML). - A user without the role gets
403; one with it gets in. - A tampered, garbage, foreign or expired cookie is anonymous, never a 500.
- A custom
bearer_verifieris tried first, falls back to API tokens, and a raising one is logged and anonymous. - Bearer tokens work; session and API tokens cannot stand in for each other.
- Five bad logins lock the name with
429until the lockout ends. - WebSocket upgrades: strangers
401, wrong role403, crew101. @requireabove@app.getor@app.websocketraisesTypeErrorwhen the module is loaded; the right order guards the route.
Next Up
Forms that change things need one more guard. See CSRF Protection.