Authentication and Authorization¶
Now that we can handle forms and sessions, let’s build on that to build a website with signup and login forms.
We’ll build on our food-rating wiki example, and modify it to have to have user accounts. Let’s begin by changing our schema to include the user who posted the rating.
foodTable = """
CREATE TABLE food (
name VARCHAR NOT NULL,
rating INTEGER NOT NULL,
rated_by VARCHAR NOT NULL,
FOREIGN KEY(rated_by)
REFERENCES account(account_id)
ON DELETE CASCADE
)
"""
We are adding a rated_by
column with a foreign key constraint on
account
. But where did account
come from?
In order to do anything useful within a database with authentication, we need to be able to relate to the account and session tables, so they are considered part of Klein’s public API. For reference, here is that full schema:
-- `session` identifies individual clients with a particular set of
-- capabilities. the `session_id` is the secret held by the client.
CREATE TABLE session (
session_id VARCHAR NOT NULL,
confidential BOOLEAN NOT NULL,
created REAL NOT NULL,
mechanism TEXT NOT NULL,
PRIMARY KEY (session_id)
);
-- `account` is a user with a name and password. the password_blob is computed
-- by the password engine in klein.storage.passwords.
CREATE TABLE account (
account_id VARCHAR NOT NULL,
username VARCHAR NOT NULL,
email VARCHAR NOT NULL,
password_blob VARCHAR NOT NULL,
PRIMARY KEY (account_id),
UNIQUE (username)
);
-- `session_account` is a record of which acccount is logged in to which session.
CREATE TABLE session_account (
account_id VARCHAR,
session_id VARCHAR,
UNIQUE (account_id, session_id),
FOREIGN KEY(account_id)
REFERENCES account (account_id)
ON DELETE CASCADE,
FOREIGN KEY(session_id)
REFERENCES session (session_id)
ON DELETE CASCADE
);
Next, we’ll need to split up our database interface. Previously, we only had one authorized object, for all clients. However, now we have two classes of client: those logged in to an account, and those not logged in to an account. We only want to allow ratings for those who have signed up and logged in.
On the front page, we will want to display a bunch of ratings by different
users, with links to their user pages. So we will need a new NamedRating
class which combines the rating with a username rather than an account ID, to
make the presentation of the URLs nice; we don’t want them to include the
opaque blobs used for account IDs. We’ll also need a query to build those.
So, here are our new queries; we need one for just the top 10 ratings, and then
one that gives us all the ratings by a given user:
class PublicRatingsDB(Protocol):
@query(
sql="""
select name, rating, rated_by from food
join account on(food.rated_by = account.account_id)
where account.username = {userName}
""",
load=many(FoodRating),
)
def ratingsByUserName(self, userName: str) -> AsyncIterable[FoodRating]:
...
@query(
sql="""
select name, rating, account.username from food
join account on(food.rated_by = account.account_id)
order by rating desc
limit 10
""",
load=many(NamedRating),
)
def topRatings(self) -> AsyncIterable[NamedRating]:
...
Next, we will need our private queries interface, the one you only get if you’re logged in.
class RatingsDB(Protocol):
@query(
sql="select name, rating, rated_by from food"
"where rated_by = {accountID}",
load=many(FoodRating),
)
def ratingsByUserID(self, accountID: str) -> AsyncIterable[FoodRating]:
...
@statement(
sql="""
insert into food (rated_by, name, rating)
values ({accountID}, {name}, {rating})
"""
)
async def addRating(self, accountID: str, name: str, rating: int) -> None:
...
Similar to before, we have an authorizer that allows everyone access to the public ratings:
@dataclass
class RatingsViewer:
db: PublicRatingsDB
def ratingsByUserName(self, userName: str) -> AsyncIterable[FoodRating]:
return self.db.ratingsByUserName(userName)
def topRatings(self) -> AsyncIterable[NamedRating]:
return self.db.topRatings()
@authorizerFor(RatingsViewer)
async def authorizeRatingsViewer(
store: ISessionStore, conn: AsyncConnection, session: ISession
) -> RatingsViewer:
return RatingsViewer(accessPublicRatings(conn))
But now, we have the slight additional complexity of conditional
authorization. Our authenticated-user authorization, FoodCritic
, needs to
return None
from its authorizer if you’re not logged in:
@dataclass
class FoodCritic:
db: RatingsDB
account: ISimpleAccount
def myRatings(self) -> AsyncIterable[FoodRating]:
return self.db.ratingsByUserID(self.account.accountID)
async def rateFood(self, name: str, rating: int) -> None:
return await self.db.addRating(self.account.accountID, name, rating)
@authorizerFor(FoodCritic)
async def authorizeFoodCritic(
store: ISessionStore, conn: AsyncConnection, session: ISession
) -> Optional[FoodCritic]:
accts = await (await session.authorize([ISimpleAccountBinding]))[
ISimpleAccountBinding
].boundAccounts()
if not accts:
return None
return FoodCritic(accessRatings(conn), accts[0])
SQLSessionProcurer
provides built-in authorizers for Klein’s built-in
account functionality, ISimpleAccountBinding
and ISimpleAccount
. So
here we ask the session to authorize us an ISimpleAccountBinding
to see
which accounts our session is bound to. If it we find one, then we can return
a FoodCritic
wrapped around it; the FoodCritic
remembers its user and
performs all its operations with that account ID. If we can’t, then we return
None
.
Note
The interfaces for ISimpleAccount
and ISimpleAccountBinding
begin
with the word “simple” because Klein’s built-in account system is
deliberately simplistic. It is intended to be easy to get started with and
suitable for light production workloads, but is not intended to be an
all-encompassing way that all Klein applications should perform their
account management; not all systems have usernames, not all systems have
passwords, and not all systems use a relational database.
If you have your own existing datastore, your own way of accessing your
RDBMS, or your own authentication system, you will want to look into
implementing your own version of the ISessionStore
and ISession
interfaces; in particular ISession.authorize
is the back-end for
Authorization
. Once you have one, you can set up your ISession
prerequisite to use SessionProcurer
with your own ISessionStore
, and
all the route-level logic ought to look similar, modulo whatever access
pattern your data store requires.
So now our database and model supports our new authenticated/unauthenticated distinction. But this doesn’t do us any good if we can’t sign up for the site, or log in to it. So let’s make some routes that can do just that:
@requirer.require(
page.routed(
app.route("/signup", methods=["POST"]),
tags.h1("signed up", slot("signedUp")),
),
username=Field.text(),
password=Field.password(),
password2=Field.password(),
binding=Authorization(ISimpleAccountBinding),
)
async def signup(
username: str, password: str, password2: str, binding: ISimpleAccountBinding
) -> dict:
await binding.createAccount(username, "", password)
return {"signedUp": "yep", "headExtras": refresh("/login")}
@requirer.require(
page.routed(
app.route("/signup", methods=["GET"]),
tags.div(tags.h1("sign up pls"), slot("signupForm")),
),
theForm=Form.rendererFor(signup, action="/signup"),
)
async def showSignup(theForm: RenderableForm) -> dict:
return {"signupForm": theForm}
We have another form following the example set in the previous section.
signup
presents a form with a username and 2 password fields. Ensuring
that those fields match is left as an exercise for the reader, but we request
an Authorization
for ISimpleAccountBinding
. Once again, this
authorizer is built in to the SQL session store and is available to any user.
We create an account and send the user over to /login
. Then we render the
form in the same way as any other form, with Form.rendererFor
.
Having successfully signed up, now we need to log in.
@requirer.require(
page.routed(
app.route("/login", methods=["POST"]),
tags.div(tags.h1("logged in", slot("didlogin"))),
),
username=Field.text(),
password=Field.password(),
binding=Authorization(ISimpleAccountBinding),
)
async def login(
username: str, password: str, binding: ISimpleAccountBinding
) -> dict:
didLogIn = await binding.bindIfCredentialsMatch(username, password)
if didLogIn is not None:
return {
"didlogin": "yes",
"headExtras": refresh("/"),
}
else:
return {
"didlogin": "no",
"headExtras": refresh("/login"),
}
@requirer.require(
page.routed(app.route("/login", methods=["GET"]), slot("loginForm")),
loginForm=Form.rendererFor(login, action="/login"),
)
def loginForm(loginForm: RenderableForm) -> dict:
return {"loginForm": loginForm}
Our login form looks a lot like our signup form, but instead calls
bindIfCredentialsMatch
with the username/password credentials that we’ve
received. This returns the bound account if the credentials match, but
None
otherwise. Finally, we need a way to log out as well:
@requirer.require(
page.routed(
app.route("/logout", methods=["POST"]),
tags.div(tags.h1("logged out ", slot("didlogout"))),
),
binding=Authorization(ISimpleAccountBinding),
ignored=Field.submit("log out"),
)
async def logout(
binding: ISimpleAccountBinding,
ignored: str,
) -> dict:
await binding.unbindThisSession()
return {"didlogout": "yes", "headExtras": refresh("/")}
Here we demonstrate customizing the text on the submit button for the form, since we need some field to indicate this is indeed a form post processor; including an explicit “submit” field is how you mark an effectively no-argument form as a POSTable form route. Plus, it wouldn’t make sense for the rendered button to say “submit” with no context; “log out” makes a lot more sense.
Note
If you want to interact with a session store directly in, i.e. an
administrative command line tool rather than a Klein route, you can
instantiate a klein.storage.sql.SessionStore
directly with an
AsyncConnection
, rather than using SQLSessionProcurer
, which needs
an HTTP request.
That’s sign-up, login, and logout handled. Now we need to change the way that our application routes actually handle authorization to deal with our new logged-in/logged-out split. First, let’s look at our food-rating post handler:
@page.widgeted
def notLoggedIn() -> dict:
return {Plating.CONTENT: "You are not logged in."}
@requirer.require(
page.routed(
app.route("/rate-food", methods=["POST"]),
tags.h1("Rated Food: ", slot("name")),
),
name=Field.text(),
rating=Field.number(minimum=1, maximum=5, kind=int),
critic=Authorization(
FoodCritic, whenDenied=lambda interface, instance: notLoggedIn.widget()
),
)
async def postHandler(name: str, rating: int, critic: FoodCritic) -> dict:
await critic.rateFood(name, rating)
return {
"name": name,
"rating": "\N{BLACK STAR}" * rating,
"pageTitle": "Food Rated",
"headExtras": refresh("/"),
}
Not much has changed here; we still have an Authorization
that requests a
FoodCritic
and calls a method on it. The only difference here is that
this method will no longer be called if the user is not logged in; instead,
the resource specified by whenDenied
- in other words, the simple templated
page from notLoggedIn
- will be displayed.
But surely we don’t even want to show the form to the user if they’re not
logged in, right? Just the top ratings, with the option to log in. How can we
accomplish that? We don’t want the presence of an Authorization
requesting
the FoodCritic
on the front page to simply fail and show the user an
error, that would be a pretty annoying user experience. What we use here is an
Authorization
with required=False
; that will give us a conditional
authorization that passes None
if it cannot be authorized, so we take a
FoodCritic | None
as our parameter, like so:
@requirer.require(
page.routed(
app.route("/", methods=["GET"]),
tags.div(
tags.ul(tags.li(render="foods:list")(slot("item"))),
tags.div(slot("rateFoodForm")),
),
),
ratingForm=rateFoodForm,
critic=Authorization(FoodCritic, required=False),
viewer=Authorization(RatingsViewer),
)
async def frontPage(
ratingForm: RenderableForm,
critic: Optional[FoodCritic],
viewer: RatingsViewer,
) -> dict:
allRatings = []
async for eachFood in viewer.topRatings():
allRatings.append(
linkedFood(
name=eachFood.name,
rating="\N{BLACK STAR}" * eachFood.rating,
username=eachFood.username,
)
)
return {
"foods": allRatings,
"rateFoodForm": "" if critic is None else ratingForm,
"pageTitle": "top-rated foods",
}
We require an Authorization
for a FoodCritic
conditionally, but we
require RatingsViewer
unconditionally, mirroring the way the page is
actually displayed. We want to see the top ratings regardless, but the form
only when we’re logged in. Note that our topRatings
method is now giving
us NamedRating
objects, and thus we use a new linkedFood
fragment to
display them with a hyperlink.