Sign out everywhere¶
A list of the user's devices, with a button to end every other session. Built on revocable sessions.
import hashlib
import hmac
import os
import secrets
from typing import Annotated
from fastapi import FastAPI, Request, Response, Security
from pydantic import BaseModel
from fastapi_multiauth import APIKeyCookieAuth, UnauthorizedError
SECRET_KEY = os.environ["SECRET_KEY"]
def hash_password(password: str, salt: bytes | None = None) -> bytes:
salt = salt or secrets.token_bytes(16)
return salt + hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
def check_password(password: str, stored: bytes) -> bool:
return hmac.compare_digest(hash_password(password, stored[:16]), stored)
PASSWORDS = {"alice": hash_password("wonderland")}
NO_PASSWORD = hash_password("")
SESSIONS: dict[str, dict] = {}
class Credentials(BaseModel):
username: str
password: str
async def validate_session(username: str, *, session_id: str) -> str:
if session_id not in SESSIONS:
raise UnauthorizedError()
return username
session = APIKeyCookieAuth(
name="__Host-session",
validator=validate_session,
secret_key=SECRET_KEY,
session_id=True,
)
CurrentUser = Annotated[str, Security(session)]
app = FastAPI()
@app.post("/login")
async def login(credentials: Credentials, request: Request, response: Response):
stored = PASSWORDS.get(credentials.username, NO_PASSWORD)
if (
not check_password(credentials.password, stored)
or credentials.username not in PASSWORDS
):
raise UnauthorizedError()
session_id = session.set_cookie(response, credentials.username)
assert session_id is not None
SESSIONS[session_id] = {
"username": credentials.username,
"device": request.headers.get("user-agent", "unknown"),
}
return {"ok": True}
@app.get("/sessions")
async def list_sessions(request: Request, username: CurrentUser):
current = session.session_id_of(request)
return [
{"device": row["device"], "current": session_id == current}
for session_id, row in SESSIONS.items()
if row["username"] == username
]
@app.post("/sessions/revoke-others")
async def revoke_others(request: Request, username: CurrentUser):
current = session.session_id_of(request)
for session_id, row in list(SESSIONS.items()):
if row["username"] == username and session_id != current:
del SESSIONS[session_id]
return {"ok": True}
@app.post("/logout")
async def logout(request: Request, response: Response):
session_id = session.delete_cookie(response, request)
SESSIONS.pop(session_id or "", None)
return {"ok": True}
session_id_of(request)tells which session is making the request, to mark it as "this device".- Never show the session id itself: it is a credential.
- Login checks the password as in cookie sessions, and a session row is only created once it matches.