Passwords¶
Experimental
New in 0.7: its API may still change in any release, as its use shows what's missing. Scope and stability lists the tools that are settled.
Passwords from sign-up to reset: kept as slow hashes, checked against those known to be leaked,
guessed at most a few times per account, and reset by a link that works once. FastAPIPasswords,
with nothing to install: the hashes are scrypt's, from the standard library. The leak check needs
the passwords extra.
Every class and function: the API reference.
from fastapi_alcazaba import FastAPIPasswords
from fastapi_alcazaba.auth import PwnedPasswords
passwords = FastAPIPasswords(
pepper=settings.password_pepper, # a SecretStr of 32 bytes or more
pwned=PwnedPasswords(),
audit=audit,
)
Signing up¶
@app.post("/signup")
async def signup(form: SignUp) -> dict[str, str]:
problems = await passwords.check(form.password, user_inputs=[form.email, form.name])
if problems:
raise HTTPException(422, {"password": problems})
user = await db.users.create(email=form.email, password=await passwords.hash(form.password))
...
- What
checkrefuses, as NIST SP 800-63B says: fewer thanmin_lengthcharacters (15 by default, the length for a password that is the only factor; 8 with a second factor), more than 1024, the e-mail address or the name in it, and a password among those leaked. No rules on its characters (a digit, a capital, a symbol): length is what makes a password strong. - Leaked passwords are the first that attackers try.
PwnedPasswordsasks Have I Been Pwned without sending the password: only the first five characters of its SHA-1 go out, and the answer is padded, so that its size doesn't tell them either. When the API doesn't answer, the password is taken as not leaked and a warning logged (fail_open=Falseraises instead). - The hash: scrypt (RFC 7914) with OWASP's parameters (128 MiB, a fifth of a second), a
random salt each, in a thread, in PHC's format:
$scrypt$ln=17,r=8,p=1$<salt>$<hash>. - The pepper: the password is first an HMAC with it, so that a stolen database without the pepper gives nothing to guess against. Keep it out of the database, in your settings: it can't be rotated without the passwords.
Logging in¶
@app.post("/login")
async def login(request: Request, form: Login) -> dict[str, str]:
user = await db.users.by_email(form.email)
user_id, stored = (user.id, user.password) if user else (None, None)
if not await passwords.verify(user_id, form.password, stored, request=request):
raise HTTPException(401, "Wrong e-mail or password")
if passwords.needs_rehash(user.password):
await db.users.update(user.id, password=await passwords.hash(form.password))
log_in(request, user)
...
- Guesses per account: after
max_attemptswrong passwords (10), the account's logins are refused forlockoutseconds (900), the right password too, in every process. Other accounts aren't affected. One client trying many accounts is a rate limit's: one per IP on the login route. - Unknown accounts take as long: with
stored=None,verifyhashes against a made-up hash, so the time of an answer doesn't tell which e-mail addresses have an account. Answer both cases with the same message. - Stronger parameters later:
needs_rehashis true for a hash made with others thanparameters; hash the password again while you have it. - When the backend fails, the attempts can't be counted, and the password is refused.
Resetting¶
@app.post("/password/forgot")
async def forgot(form: Forgot) -> None:
user = await db.users.by_email(form.email)
if user is not None:
token = await passwords.reset_token(user.id)
await mail.send(user.email, "Reset your password", f"{settings.url}/reset?token={token}")
# the same answer either way
@app.post("/password/reset")
async def reset(request: Request, form: Reset) -> None:
if problems := await passwords.check(form.password):
raise HTTPException(422, {"password": problems})
user_id = await passwords.use_reset_token(form.token, request=request)
if user_id is None:
raise HTTPException(400, "The link expired or was used: ask for another")
await db.users.update(user_id, password=await passwords.hash(form.password))
await user_sessions.end_all(user_id) # whoever knew the old password is logged out
- A token, once: 256 random bits, kept as a digest for
reset_ttlseconds (an hour). Of two requests with it, one gets the user; a newer token replaces the user's last one. - The user ID comes back as a string, as the backend keeps it.
- Send it by e-mail (FastAPIMail), in the path or the body of the reset page's
form, never in a page's links to other sites: the
Refererwould carry it.
What's recorded¶
With audit, each password checked is an auth.password event, and each reset an
auth.password_reset, with the outcome as reason when it failed (wrong, locked, unknown,
replaced...). The metric fastapi_alcazaba.passwords counts the same by outcome: a burst of
wrong or locked is someone guessing. Neither ever has the password, nor the token.