Signing in¶
Without an auth provider the admin is open to anyone who can reach it. That is fine behind a VPN or on your own machine, and nowhere else.
A fixed list of users¶
from adminsite.auth import PasswordAuth
admin = Admin(
engine,
views=[OrderView],
auth=PasswordAuth({"nima": settings.admin_password_hash}),
secret_key=settings.admin_secret_key,
)
PasswordAuth takes password hashes, never passwords, and refuses a plain one at startup. Make a
hash once and keep the result in your settings:
python -c "from adminsite.auth import hash_password; print(hash_password('your password'))"
Hashes use PBKDF2 with SHA-256 and 600,000 rounds, from the standard library.
secret_key signs the session cookie. Keep it secret, make it long and random, and read it from
your settings. Passing auth without one is refused at startup.
Your own user table¶
For anything more than a handful of people, subclass AuthProvider and check your own users:
from sqlalchemy import select
from adminsite.auth import AuthProvider, SignInRefused, verify_password
class StaffAuth(AuthProvider):
async def verify(self, username: str, password: str):
async with session_factory() as session:
user = await session.scalar(select(User).where(User.email == username))
if user is None or not verify_password(password, user.password_hash):
return None
if not user.is_staff:
raise SignInRefused("Not a member of staff.", user=user)
return user
def identity(self, user) -> str:
return str(user.id)
async def load_user(self, key: str):
async with session_factory() as session:
return await session.get(User, int(key))
| Method | What it does |
|---|---|
verify(username, password) |
Returns the user for these details, or None, or raises SignInRefused. |
identity(user) |
The short string kept in the session cookie. |
load_user(key) |
Turns that string back into a user on each request. |
sign_in_failed(request, username) |
Runs when a sign in fails, and returns what to say. |
may_read_sign_ins(request, reads_everything=...) |
Whether this person sees sign ins on the Activity page. |
SignInRefused(reason, user=...) refuses a sign in and says why. The reason goes to the
audit log, filed under that user, and the person signing in is told only what
sign_in_failed returns. PasswordAuth gives "There is no such username." or "The password was
wrong."
The loaded user is on request.scope["user_record"], for your permission checks.
Its str() is what the sidebar and the audit log show, and identity(user) is the key the audit
log files it under.
When a sign in fails¶
The login page says "That username and password do not match." Override sign_in_failed to say
something else, and to do something about it. With the audit log on, every attempt is
already written down; sign_in_failed is where an alert or a wait belongs:
class StaffAuth(AuthProvider):
async def sign_in_failed(self, request, username: str) -> str:
await note_attempt(username, request.client.host)
if await too_many_lately(request.client.host):
return "Too many tries. Wait a minute and try again."
return await super().sign_in_failed(request, username)
Keep the message vague. One that says the username exists tells whoever is guessing the same thing.
CSRF¶
Every form the admin draws carries a token tied to the session, and every post is checked against
it, including deletes and actions. A post without the right token is refused with a 403. Scripts
that post to the admin can send the token in an X-CSRF-Token header instead of a form field.
The JSON API also takes a bearer token, once your provider's authenticate_token
says who it belongs to.
Signing in starts a fresh session, so a token taken from the login page stops working once the person is signed in. A script that drives the admin through a session takes its token from a page drawn after signing in.
Without a secret_key there is no session, and so no token. That does not leave the admin open to
a form posted from another site: browsers say where a post came from, in the Sec-Fetch-Site and
Origin headers, and a post from elsewhere is refused. A request that carries neither, from a
script or curl, goes through, as it did before. This matters for an admin left open on a private
network, where the browser of anyone on that network could otherwise be made to post to it.
The session cookie¶
The cookie is signed with secret_key and lasts two weeks. Serve the admin over HTTPS and say so,
and the cookie is never sent over plain HTTP:
admin = Admin(
engine, auth=auth, secret_key=..., session_https_only=True, session_max_age=8 * 3600
)
session_max_age=None keeps the cookie for the browser session only.