diff --git a/fasthtml/_modidx.py b/fasthtml/_modidx.py index 412a2a14..79d50440 100644 --- a/fasthtml/_modidx.py +++ b/fasthtml/_modidx.py @@ -186,6 +186,38 @@ 'fasthtml.jupyter.wait_port_free': ('api/jupyter.html#wait_port_free', 'fasthtml/jupyter.py'), 'fasthtml.jupyter.ws_client': ('api/jupyter.html#ws_client', 'fasthtml/jupyter.py')}, 'fasthtml.live_reload': {}, + 'fasthtml.magickey': { 'fasthtml.magickey.MagicKey': ('api/magickey.html#magickey', 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey.__init__': ('api/magickey.html#magickey.__init__', 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey._do_auth': ('api/magickey.html#magickey._do_auth', 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey._finish_passkey_reg': ( 'api/magickey.html#magickey._finish_passkey_reg', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey._logout': ('api/magickey.html#magickey._logout', 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey._request_passkey_auth': ( 'api/magickey.html#magickey._request_passkey_auth', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey._request_passkey_reg': ( 'api/magickey.html#magickey._request_passkey_reg', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey._send_magic_link': ( 'api/magickey.html#magickey._send_magic_link', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey._skip_passkey_reg': ( 'api/magickey.html#magickey._skip_passkey_reg', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey._verify_magiclink': ( 'api/magickey.html#magickey._verify_magiclink', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey._verify_passkey_auth': ( 'api/magickey.html#magickey._verify_passkey_auth', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey.after_magiclink_verify': ( 'api/magickey.html#magickey.after_magiclink_verify', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey.get_auth': ('api/magickey.html#magickey.get_auth', 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey.get_passkey': ( 'api/magickey.html#magickey.get_passkey', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey.get_user_id': ( 'api/magickey.html#magickey.get_user_id', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey.has_passkey': ( 'api/magickey.html#magickey.has_passkey', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey.save_passkey': ( 'api/magickey.html#magickey.save_passkey', + 'fasthtml/magickey.py'), + 'fasthtml.magickey.MagicKey.update_passkey': ( 'api/magickey.html#magickey.update_passkey', + 'fasthtml/magickey.py'), + 'fasthtml.magickey._webauthn_js': ('api/magickey.html#_webauthn_js', 'fasthtml/magickey.py')}, 'fasthtml.oauth': { 'fasthtml.oauth.AppleAppClient': ('api/oauth.html#appleappclient', 'fasthtml/oauth.py'), 'fasthtml.oauth.AppleAppClient.__init__': ('api/oauth.html#appleappclient.__init__', 'fasthtml/oauth.py'), 'fasthtml.oauth.AppleAppClient.client_secret': ( 'api/oauth.html#appleappclient.client_secret', @@ -254,6 +286,19 @@ 'fasthtml.pico.PicoBusy': ('api/pico.html#picobusy', 'fasthtml/pico.py'), 'fasthtml.pico.Search': ('api/pico.html#search', 'fasthtml/pico.py'), 'fasthtml.pico.set_pico_cls': ('api/pico.html#set_pico_cls', 'fasthtml/pico.py')}, + 'fasthtml.ratelimit': { 'fasthtml.ratelimit.Limiter': ('api/ratelimit.html#limiter', 'fasthtml/ratelimit.py'), + 'fasthtml.ratelimit.Limiter.__call__': ('api/ratelimit.html#limiter.__call__', 'fasthtml/ratelimit.py'), + 'fasthtml.ratelimit.Limiter.__init__': ('api/ratelimit.html#limiter.__init__', 'fasthtml/ratelimit.py'), + 'fasthtml.ratelimit.TokenBucket': ('api/ratelimit.html#tokenbucket', 'fasthtml/ratelimit.py'), + 'fasthtml.ratelimit.TokenBucket.__init__': ( 'api/ratelimit.html#tokenbucket.__init__', + 'fasthtml/ratelimit.py'), + 'fasthtml.ratelimit.TokenBucket.__repr__': ( 'api/ratelimit.html#tokenbucket.__repr__', + 'fasthtml/ratelimit.py'), + 'fasthtml.ratelimit.TokenBucket._prune': ( 'api/ratelimit.html#tokenbucket._prune', + 'fasthtml/ratelimit.py'), + 'fasthtml.ratelimit.TokenBucket.wait': ('api/ratelimit.html#tokenbucket.wait', 'fasthtml/ratelimit.py'), + 'fasthtml.ratelimit.client_ip': ('api/ratelimit.html#client_ip', 'fasthtml/ratelimit.py'), + 'fasthtml.ratelimit.parse_rate': ('api/ratelimit.html#parse_rate', 'fasthtml/ratelimit.py')}, 'fasthtml.starlette': {}, 'fasthtml.stripe_otp': { 'fasthtml.stripe_otp.Payment': ('explains/stripe.html#payment', 'fasthtml/stripe_otp.py'), 'fasthtml.stripe_otp._search_app': ('explains/stripe.html#_search_app', 'fasthtml/stripe_otp.py'), diff --git a/fasthtml/magickey.py b/fasthtml/magickey.py new file mode 100644 index 00000000..92d78b2b --- /dev/null +++ b/fasthtml/magickey.py @@ -0,0 +1,165 @@ +"""Passwordless auth combining magic links and passkeys""" + +# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/10_magickey.ipynb. + +# %% auto #0 +__all__ = ['MagicKey'] + +# %% ../nbs/api/10_magickey.ipynb #d3fd43fe +import secrets, time +from json import loads +from urllib.parse import urlparse + +from webauthn import generate_authentication_options, generate_registration_options, verify_authentication_response, verify_registration_response, options_to_json +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url +from webauthn.helpers.structs import AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement + +from fastcore.utils import * +from fastcore.xml import * +from fastlite import * +from .basics import * +from .starlette import * +from .fastapp import * +from .ratelimit import * + +# %% ../nbs/api/10_magickey.ipynb #03d86e44 +# _origin_kw removed; public_origin / rp_id inlined at call sites + + +# %% ../nbs/api/10_magickey.ipynb #02b50590 +def _webauthn_js(opts, action, callback): + return Script("SimpleWebAuthnBrowser.%s({ optionsJSON: %s }).then(r => {" + "htmx.ajax('POST', '%s', { values: r });});" % (action, options_to_json(opts), callback)) + +# %% ../nbs/api/10_magickey.ipynb #032d5010 +class MagicKey: + def __init__(self, + app, # FastHTML app instance + send_email, # `f(email, magic_url)` — sends the magic link + public_origin, # Full origin URL, e.g. `https://example.com` + rp_id=None, # WebAuthn relying party ID (default: hostname from `public_origin`) + rp_name=None, # WebAuthn relying party display name (default: `rp_id`) + skip=None, # Routes to skip auth beforeware (default: all MagicKey routes) + login_path='/login', # Login page route + logout_path='/logout', # Logout route + token_expiry=3600, # Magic link validity in seconds + email_rate='3/m', # Per-email rate limit on magic link sends (None to disable) + ip_rate='10/m', # Per-IP rate limit on magic link sends (None to disable) + webauthn_js=None): # Script tag or URL for SimpleWebAuthn browser JS (default: jsdelivr CDN) + "Passwordless auth combining magic links and passkeys" + if not rp_id: rp_id = urlparse(public_origin).hostname + self.magiclink_db = {} + self._email_bucket = TokenBucket(email_rate) if email_rate else None + self._ip_bucket = TokenBucket(ip_rate) if ip_rate else None + store_attr() + async def _before(req, session): + if 'auth' not in req.scope: req.scope['auth'] = session.get('auth') + if not req.scope['auth']: return RedirectResponse(login_path, status_code=303) + if not skip: skip = [login_path, logout_path, '/finish_passkey_reg', '/send_magic_link', + '/request_passkey_auth', '/request_passkey_reg', '/setup_passkey', + '/skip_passkey_reg', '/verify_magiclink', '/verify_passkey_auth',] + app.before.append(Beforeware(_before, skip=skip)) + if not webauthn_js: webauthn_js = Script(src='https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@13.1.0/dist/bundle/index.umd.min.js') + elif isinstance(webauthn_js, str): webauthn_js = Script(src=webauthn_js) + app.hdrs += (webauthn_js,) + app.get(logout_path)(self._logout) + app.post('/send_magic_link')(self._send_magic_link) + app.get('/verify_magiclink')(self._verify_magiclink) + app.post('/request_passkey_auth')(self._request_passkey_auth) + app.post('/verify_passkey_auth')(self._verify_passkey_auth) + app.post('/request_passkey_reg')(self._request_passkey_reg) + app.post('/finish_passkey_reg')(self._finish_passkey_reg) + app.post('/skip_passkey_reg')(self._skip_passkey_reg) + + def _logout(self, session): + session.pop('auth', None) + return RedirectResponse(self.login_path, status_code=303) + + def _send_magic_link(self, email: str, req): + for bucket,key in ((self._ip_bucket, client_ip(req)), (self._email_bucket, email)): + if bucket and (w:=bucket.wait(key)): return Response('Too many requests', status_code=429, headers={'Retry-After': str(int(w)+1)}) + self.magiclink_db = {k:v for k,v in self.magiclink_db.items() if not v['used'] and time.time() - v['timestamp'] <= self.token_expiry} + token = secrets.token_urlsafe(32) + self.magiclink_db[token] = dict(email=email, timestamp=time.time(), used=False) + magic_url = f'{self.public_origin}/verify_magiclink?token={token}' + return self.send_email(email, magic_url) + + def _verify_magiclink(self, token: str, session, req): + link = self.magiclink_db.get(token) + if not link or link['used'] or time.time() - link['timestamp'] > self.token_expiry: + return RedirectResponse(f'{self.login_path}?error=invalid_link', status_code=303) + link['used'] = True + return self.after_magiclink_verify(link['email'], session, req) + + def _request_passkey_auth(self, session): + auth_opts = generate_authentication_options( + rp_id=self.rp_id, user_verification=UserVerificationRequirement.REQUIRED) + session['auth_challenge'] = bytes_to_base64url(auth_opts.challenge) + return _webauthn_js(auth_opts, 'startAuthentication', '/verify_passkey_auth') + + def _verify_passkey_auth(self, response: str, id: str, rawId: str, type: str, session): + challenge_b64 = session.pop('auth_challenge', None) + if not challenge_b64: return HttpHeader('HX-Redirect', f'{self.login_path}?error=no_challenge') + stored = self.get_passkey(id) + if not stored: return HttpHeader('HX-Redirect', f'{self.login_path}?error=passkey_not_found') + try: + res = verify_authentication_response( + credential=dict(id=id, rawId=rawId, response=loads(response), type=type), + credential_public_key=stored['public_key'], + credential_current_sign_count=stored['sign_count'], + expected_challenge=base64url_to_bytes(challenge_b64), + require_user_verification=True, + expected_origin=self.public_origin, expected_rp_id=self.rp_id) + except Exception: return HttpHeader('HX-Redirect', f'{self.login_path}?error=passkey_failed') + self.update_passkey(id, res.new_sign_count) + session['auth'] = self.get_user_id(stored['email']) + return self._do_auth(session['auth'], session, htmx=True) + + def _request_passkey_reg(self, session): + email = session.get('pending_email') + if not email: return RedirectResponse(f'{self.login_path}?error=no_pending_reg', status_code=303) + opts = generate_registration_options( + rp_id=self.rp_id, rp_name=self.rp_name or self.rp_id, user_name=email, + user_id=str(self.get_user_id(email)).encode(), + authenticator_selection=AuthenticatorSelectionCriteria(resident_key=ResidentKeyRequirement.REQUIRED)) + session['reg_challenge'] = bytes_to_base64url(opts.challenge) + return _webauthn_js(opts, 'startRegistration', '/finish_passkey_reg') + + def _finish_passkey_reg(self, response: str, id: str, rawId: str, type: str, session): + email = session.pop('pending_email', None) + if not email: return RedirectResponse(f'{self.login_path}?error=no_pending_reg', status_code=303) + challenge_b64 = session.pop('reg_challenge', None) + if not challenge_b64: return RedirectResponse('/setup_passkey?error=no_challenge', status_code=303) + try: res = verify_registration_response(credential=dict(id=id, rawId=rawId, response=loads(response), type=type), + expected_challenge=base64url_to_bytes(challenge_b64), + require_user_verification=True, + expected_origin=self.public_origin, expected_rp_id=self.rp_id) + except Exception: return RedirectResponse('/setup_passkey?error=reg_failed', status_code=303) + self.save_passkey(bytes_to_base64url(res.credential_id), email, res.credential_public_key, res.sign_count) + session['auth'] = self.get_user_id(email) + return self._do_auth(session['auth'], session, htmx=True) + + def _skip_passkey_reg(self, session): + email = session.pop('pending_email', None) + if not email: return RedirectResponse(self.login_path, status_code=303) + session['auth'] = self.get_user_id(email) + return self._do_auth(session['auth'], session, htmx=False) + + def _do_auth(self, user_id, session, htmx=False): + url = self.get_auth(user_id, session) + if htmx: return HttpHeader('HX-Redirect', url) + return RedirectResponse(url, status_code=303) + + def get_auth(self, user_id, session): return '/' + def get_user_id(self, email): raise NotImplementedError() + def has_passkey(self, email): raise NotImplementedError() + def get_passkey(self, credential_id): raise NotImplementedError() + def save_passkey(self, credential_id, email, public_key, sign_count): raise NotImplementedError() + def update_passkey(self, credential_id, sign_count): raise NotImplementedError() + + def after_magiclink_verify(self, email, session, req): + if self.has_passkey(email): + session['auth'] = self.get_user_id(email) + return self._do_auth(session['auth'], session, htmx=False) + session['pending_email'] = email + return RedirectResponse('/setup_passkey', status_code=303) diff --git a/fasthtml/ratelimit.py b/fasthtml/ratelimit.py new file mode 100644 index 00000000..9c3ded92 --- /dev/null +++ b/fasthtml/ratelimit.py @@ -0,0 +1,83 @@ +"""Simple token-bucket rate limiting for FastHTML routes""" + +# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/07_ratelimit.ipynb. + +# %% auto #0 +__all__ = ['parse_rate', 'TokenBucket', 'client_ip', 'Limiter'] + +# %% ../nbs/api/07_ratelimit.ipynb #b33aec8a +import asyncio,re,time +from contextvars import ContextVar +from functools import wraps +from math import ceil +from typing import Callable +from starlette.responses import Response +from fastcore.utils import * + +# %% ../nbs/api/07_ratelimit.ipynb #1078d10b +_units = dict(s=1, m=60, h=3600, d=86400) + +def parse_rate(s): + "Parse rate string like `'5/m'`, `'1 per day'`, `'100/2h'`" + m = re.match(r'(\d+)\s*(?:/|per)\s*(\d+)?\s*([smhd])', s, re.I) + if not m: raise ValueError(f"Invalid rate: {s}") + n,mult,unit = m.groups() + return int(n), _units[unit.lower()] * int(mult or 1) + +# %% ../nbs/api/07_ratelimit.ipynb #7b28079a +class TokenBucket: + "Token-bucket rate limiter" + def __init__(self, + max_reqs:str|int, # Rate string ('5/m') or max requests per window + window_secs:int=None # Window in seconds (required if `max_reqs` is int) + ): + if window_secs is None: max_reqs,window_secs = parse_rate(max_reqs) + store_attr() + self.rate = max_reqs / window_secs + self.buckets = {} + def __repr__(self): return f'TokenBucket({self.max_reqs}, {self.window_secs})' + + def _prune(self): + cutoff = time.time() - self.window_secs + self.buckets = {k:(t,ts) for k,(t,ts) in self.buckets.items() if ts > cutoff} + def wait(self, key): + "Return 0 if allowed, else seconds to wait" + self._prune() + now = time.time() + tokens, last = self.buckets.get(key, (self.max_reqs, now)) + tokens = min(self.max_reqs, tokens + (now - last) * self.rate) + if tokens < 1: return (1 - tokens) / self.rate + self.buckets[key] = (tokens - 1, now) + return 0 + +# %% ../nbs/api/07_ratelimit.ipynb #69b0fb44 +def client_ip(req, **kwargs): + "Get client IP from `X-Forwarded-For` header, falling back to `req.client.host`" + return req.headers.get('x-forwarded-for', '').split(',')[0].strip() or (req.client and req.client.host) or '' + +# %% ../nbs/api/07_ratelimit.ipynb #75b5b395 +class Limiter: + "Rate limiter for FastHTML routes" + def __init__(self, app): + self._req_var = ContextVar('limiter_req') + async def _store(req): self._req_var.set(req) + app.before.insert(0, _store) + + def __call__(self, + rate:str|tuple, # Rate string ('5/m') or (max_reqs, window_secs) tuple + key:str|Callable=client_ip, # Key to limit by: route param name, or callable(req, **kwargs) + on_limit:Callable=None # Optional callback(wait_secs) to return custom response on 429 + ): + "Rate limit decorator: `@limiter('5/m')`" + bucket = TokenBucket(*rate) if isinstance(rate, tuple) else TokenBucket(rate) + def decorator(f): + @wraps(f) + async def wrapper(*args, **kwargs): + req = self._req_var.get() + k = key(req, **kwargs) if callable(key) else str(kwargs.get(key, '')) + if w:=bucket.wait(k): + if on_limit: return on_limit(w) + return Response('Too many requests', status_code=429, headers={'Retry-After': str(ceil(w))}) + return await maybe_await(f(*args, **kwargs)) + return wrapper + return decorator diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 5232d7e4..e4bfb90d 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -136,9 +136,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "datetime" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -159,9 +157,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "bool" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -196,9 +192,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -268,9 +262,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "HtmxHeaders" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -437,9 +429,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -475,9 +465,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "HttpHeader" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -512,9 +500,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "dict" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -537,9 +523,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "dict" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -760,9 +744,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -855,9 +837,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "Foo" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -972,9 +952,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2120,9 +2098,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2187,9 +2163,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2214,9 +2188,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2314,9 +2286,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2383,9 +2353,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2439,9 +2407,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2481,9 +2447,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2508,9 +2472,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2534,9 +2496,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2591,9 +2551,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2691,9 +2649,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2740,9 +2696,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2930,9 +2884,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -2960,9 +2912,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -3287,9 +3237,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "_mk_locfunc.._lf" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -3941,9 +3889,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "_mk_locfunc.._lf" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -4396,9 +4342,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "_mk_locfunc.._lf" - }, + "metadata": {}, "output_type": "execute_result" } ], diff --git a/nbs/api/01_components.ipynb b/nbs/api/01_components.ipynb index 5f8943f8..31bf97d7 100644 --- a/nbs/api/01_components.ipynb +++ b/nbs/api/01_components.ipynb @@ -94,9 +94,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -164,9 +162,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -199,9 +195,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -234,9 +228,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -358,9 +350,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -389,9 +379,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -420,9 +408,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -461,9 +447,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -508,9 +492,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -539,9 +521,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -600,9 +580,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -646,9 +624,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -679,9 +655,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -711,9 +685,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -963,9 +935,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -1006,9 +976,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -1050,9 +1018,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "FT" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -1099,9 +1065,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "TodoItem" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -1146,9 +1110,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "list" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -1309,9 +1271,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "Markdown" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -1374,9 +1334,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "Markdown" - }, + "metadata": {}, "output_type": "execute_result" } ], diff --git a/nbs/api/07_ratelimit.ipynb b/nbs/api/07_ratelimit.ipynb new file mode 100644 index 00000000..e62b8b42 --- /dev/null +++ b/nbs/api/07_ratelimit.ipynb @@ -0,0 +1,682 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6de889b6", + "metadata": {}, + "source": [ + "# Rate Limiting\n", + "> Simple token-bucket rate limiting for FastHTML routes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec249d73", + "metadata": {}, + "outputs": [], + "source": [ + "#| default_exp ratelimit" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b33aec8a", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "import asyncio,re,time\n", + "from contextvars import ContextVar\n", + "from functools import wraps\n", + "from math import ceil\n", + "from typing import Callable\n", + "from starlette.responses import Response\n", + "from fastcore.utils import *" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1e39599", + "metadata": {}, + "outputs": [], + "source": [ + "#| hide\n", + "from starlette.testclient import TestClient\n", + "from nbdev.showdoc import show_doc\n", + "from fastcore.test import *\n", + "from fasthtml.fastapp import *" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1078d10b", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "_units = dict(s=1, m=60, h=3600, d=86400)\n", + "\n", + "def parse_rate(s):\n", + " \"Parse rate string like `'5/m'`, `'1 per day'`, `'100/2h'`\"\n", + " m = re.match(r'(\\d+)\\s*(?:/|per)\\s*(\\d+)?\\s*([smhd])', s, re.I)\n", + " if not m: raise ValueError(f\"Invalid rate: {s}\")\n", + " n,mult,unit = m.groups()\n", + " return int(n), _units[unit.lower()] * int(mult or 1)" + ] + }, + { + "cell_type": "markdown", + "id": "23e2936b", + "metadata": {}, + "source": [ + "`parse_rate` accepts strings in the form `\"{count}/{window}\"` or `\"{count} per {window}\"`, where:\n", + "\n", + "- **count** — integer number of allowed requests (e.g. `5`, `100`)\n", + "- **window** — optional multiplier + unit: `s`econds, `m`inutes, `h`ours, `d`ays\n", + "\n", + "Examples: `'5/m'`, `'100/2h'`, `'1 per day'`, `5 per 2 hours`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19fecdf0", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(parse_rate('5/m'), (5, 60))\n", + "test_eq(parse_rate('100/2h'), (100, 7200))\n", + "test_eq(parse_rate('1 per day'), (1, 86400))\n", + "test_eq(parse_rate('5 per 2 hours'), (5, 7200))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7b28079a", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "class TokenBucket:\n", + " \"Token-bucket rate limiter\"\n", + " def __init__(self,\n", + " max_reqs:str|int, # Rate string ('5/m') or max requests per window\n", + " window_secs:int=None # Window in seconds (required if `max_reqs` is int)\n", + " ):\n", + " if window_secs is None: max_reqs,window_secs = parse_rate(max_reqs)\n", + " store_attr()\n", + " self.rate = max_reqs / window_secs\n", + " self.buckets = {}\n", + " def __repr__(self): return f'TokenBucket({self.max_reqs}, {self.window_secs})'\n", + "\n", + " def _prune(self):\n", + " cutoff = time.time() - self.window_secs\n", + " self.buckets = {k:(t,ts) for k,(t,ts) in self.buckets.items() if ts > cutoff}\n", + " def wait(self, key):\n", + " \"Return 0 if allowed, else seconds to wait\"\n", + " self._prune()\n", + " now = time.time()\n", + " tokens, last = self.buckets.get(key, (self.max_reqs, now))\n", + " tokens = min(self.max_reqs, tokens + (now - last) * self.rate)\n", + " if tokens < 1: return (1 - tokens) / self.rate\n", + " self.buckets[key] = (tokens - 1, now)\n", + " return 0" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f27963b0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "---\n", + "\n", + "### TokenBucket\n", + "\n", + "```python\n", + "\n", + "def TokenBucket(\n", + " max_reqs:str | int, # Rate string ('5/m') or max requests per window\n", + " window_secs:int=None, # Window in seconds (required if `max_reqs` is int)\n", + "):\n", + "\n", + "\n", + "```\n", + "\n", + "*Token-bucket rate limiter*" + ], + "text/plain": [ + "def TokenBucket(\n", + " max_reqs:str | int, # Rate string ('5/m') or max requests per window\n", + " window_secs:int=None, # Window in seconds (required if `max_reqs` is int)\n", + "):\n", + "\"\"\"Token-bucket rate limiter\"\"\"" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "show_doc(TokenBucket)" + ] + }, + { + "cell_type": "markdown", + "id": "349d2064", + "metadata": {}, + "source": [ + "Implements the [token bucket algorithm](https://en.wikipedia.org/wiki/Token_bucket). Tokens are added at a steady rate and consumed by requests. When the bucket is empty, requests are rejected and told how long to wait." + ] + }, + { + "cell_type": "markdown", + "id": "f0028515", + "metadata": {}, + "source": [ + "You can create a bucket with either a rate string or explicit values:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "417a59a6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "TokenBucket(3, 10)" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tb = TokenBucket(3, 10)\n", + "tb" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "623ac5d8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "TokenBucket(3, 10)" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tb = TokenBucket('3/10s')\n", + "tb" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38712997", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "---\n", + "\n", + "### TokenBucket.wait\n", + "\n", + "```python\n", + "\n", + "def wait(\n", + " key\n", + "):\n", + "\n", + "\n", + "```\n", + "\n", + "*Return 0 if allowed, else seconds to wait*" + ], + "text/plain": [ + "def wait(\n", + " key\n", + "):\n", + "\"\"\"Return 0 if allowed, else seconds to wait\"\"\"" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "show_doc(TokenBucket.wait)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff831ed1", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(tb.wait('x'), 0)\n", + "test_eq(tb.wait('x'), 0)\n", + "test_eq(tb.wait('x'), 0)\n", + "test(tb.wait('x'), 0, operator.gt)" + ] + }, + { + "cell_type": "markdown", + "id": "73587949", + "metadata": {}, + "source": [ + "Each key gets its own independent token bucket. So if user A exhausts their tokens, user B is unaffected — they still have a full bucket of their own:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6d8039f8", + "metadata": {}, + "outputs": [], + "source": [ + "tb2 = TokenBucket(1, 0.5)\n", + "test_eq(tb2.wait('old'), 0)\n", + "test_eq(tb2.wait('new'), 0)\n", + "test (tb2.wait('old'), 0, operator.gt)" + ] + }, + { + "cell_type": "markdown", + "id": "2e48ec6b", + "metadata": {}, + "source": [ + "Stale keys — inactive longer than the window — are automatically pruned on each wait call:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5276e7bb", + "metadata": {}, + "outputs": [], + "source": [ + "tb2.wait('old')\n", + "time.sleep(0.6)\n", + "tb2.wait('new')\n", + "test_eq('old' in tb2.buckets, False)\n", + "test_eq('new' in tb2.buckets, True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "69b0fb44", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def client_ip(req, **kwargs):\n", + " \"Get client IP from `X-Forwarded-For` header, falling back to `req.client.host`\"\n", + " return req.headers.get('x-forwarded-for', '').split(',')[0].strip() or (req.client and req.client.host) or ''" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75b5b395", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "class Limiter:\n", + " \"Rate limiter for FastHTML routes\"\n", + " def __init__(self, app):\n", + " self._req_var = ContextVar('limiter_req')\n", + " async def _store(req): self._req_var.set(req)\n", + " app.before.insert(0, _store)\n", + "\n", + " def __call__(self,\n", + " rate:str|tuple, # Rate string ('5/m') or (max_reqs, window_secs) tuple\n", + " key:str|Callable=client_ip, # Key to limit by: route param name, or callable(req, **kwargs)\n", + " on_limit:Callable=None # Optional callback(wait_secs) to return custom response on 429\n", + " ):\n", + " \"Rate limit decorator: `@limiter('5/m')`\"\n", + " bucket = TokenBucket(*rate) if isinstance(rate, tuple) else TokenBucket(rate)\n", + " def decorator(f):\n", + " @wraps(f)\n", + " async def wrapper(*args, **kwargs):\n", + " req = self._req_var.get()\n", + " k = key(req, **kwargs) if callable(key) else str(kwargs.get(key, ''))\n", + " if w:=bucket.wait(k):\n", + " if on_limit: return on_limit(w)\n", + " return Response('Too many requests', status_code=429, headers={'Retry-After': str(ceil(w))})\n", + " return await maybe_await(f(*args, **kwargs))\n", + " return wrapper\n", + " return decorator" + ] + }, + { + "cell_type": "markdown", + "id": "066e746b", + "metadata": {}, + "source": [ + "`Limiter` integrates `TokenBucket` with FastHTML's routing. Decorate routes with `@limiter()` to apply rate limits. Multiple decorators stack — all must pass. The `key` parameter controls what to rate-limit by: by default it uses `client_ip` which extracts the client's IP from `X-Forwarded-For` (falling back to `req.client.host`). Pass a string to match a route parameter name, or a callable for custom logic. Use `on_limit` to customize the 429 response — it receives the wait time in seconds and returns the response to send." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e399153c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "---\n", + "\n", + "### Limiter\n", + "\n", + "```python\n", + "\n", + "def Limiter(\n", + " app\n", + "):\n", + "\n", + "\n", + "```\n", + "\n", + "*Rate limiter for FastHTML routes*" + ], + "text/plain": [ + "def Limiter(\n", + " app\n", + "):\n", + "\"\"\"Rate limiter for FastHTML routes\"\"\"" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "show_doc(Limiter)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9302a311", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "---\n", + "\n", + "### Limiter.__call__\n", + "\n", + "```python\n", + "\n", + "def __call__(\n", + " rate:str | tuple, # Rate string ('5/m') or (max_reqs, window_secs) tuple\n", + " key:Union=client_ip, # Key to limit by: route param name, or callable(req, **kwargs)\n", + " on_limit:Callable=None, # Optional callback(wait_secs) to return custom response on 429\n", + "):\n", + "\n", + "\n", + "```\n", + "\n", + "*Rate limit decorator: `@limiter('5/m')`*" + ], + "text/plain": [ + "def __call__(\n", + " rate:str | tuple, # Rate string ('5/m') or (max_reqs, window_secs) tuple\n", + " key:Union=client_ip, # Key to limit by: route param name, or callable(req, **kwargs)\n", + " on_limit:Callable=None, # Optional callback(wait_secs) to return custom response on 429\n", + "):\n", + "\"\"\"Rate limit decorator: `@limiter('5/m')`\"\"\"" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "show_doc(Limiter.__call__)" + ] + }, + { + "cell_type": "markdown", + "id": "3097f9d4", + "metadata": {}, + "source": [ + "## Example usage" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19967166", + "metadata": {}, + "outputs": [], + "source": [ + "app, rt = fast_app()\n", + "cli = TestClient(app)\n", + "limiter = Limiter(app)" + ] + }, + { + "cell_type": "markdown", + "id": "d0e3dd93", + "metadata": {}, + "source": [ + "### IP-based rate limiting (default)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7255f9dc", + "metadata": {}, + "outputs": [], + "source": [ + "@rt\n", + "@limiter('3/m') # keys on client IP by default\n", + "def index(): return 'ok'" + ] + }, + { + "cell_type": "markdown", + "id": "a2c4118b", + "metadata": {}, + "source": [ + "Requests are allowed until the bucket is exhausted, then a 429 is returned with a `Retry-After` header." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89f5bfcf", + "metadata": {}, + "outputs": [], + "source": [ + "for i in range(3): test_eq(cli.get('/').status_code, 200)\n", + "r = cli.get('/')\n", + "test_eq(r.status_code, 429)\n", + "test(r.headers, 'Retry-After', operator.contains)" + ] + }, + { + "cell_type": "markdown", + "id": "5c7a87db", + "metadata": {}, + "source": [ + "### Parameter-based rate limiting:" + ] + }, + { + "cell_type": "markdown", + "id": "6e1b8dc0", + "metadata": {}, + "source": [ + "Different keys get independent buckets. Here we show `a@test.com` and `b@test.com` each get their own limit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bbe72069", + "metadata": {}, + "outputs": [], + "source": [ + "@rt('/submit', methods=['POST'])\n", + "@limiter('2/m', key='email')\n", + "def submit(email: str): return f'hello {email}'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40d23160", + "metadata": {}, + "outputs": [], + "source": [ + "for i in range(2): test_eq(cli.post('/submit', data={'email': 'a@test.com'}).status_code, 200)\n", + "test_eq(cli.post('/submit', data={'email': 'a@test.com'}).status_code, 429)\n", + "test_eq(cli.post('/submit', data={'email': 'b@test.com'}).status_code, 200)" + ] + }, + { + "cell_type": "markdown", + "id": "70172d98", + "metadata": {}, + "source": [ + "### Callable-based rate limiting:" + ] + }, + { + "cell_type": "markdown", + "id": "dffebde9", + "metadata": {}, + "source": [ + "For full control, pass a callable as `key`. It receives the Starlette `Request` plus any route `**kwargs`, and should return a string to bucket by. Here we rate-limit by the `x-api-key` header — each API key gets its own bucket:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "197fb532", + "metadata": {}, + "outputs": [], + "source": [ + "@rt\n", + "@limiter('2/m', key=lambda req, **kwargs: req.headers.get('x-api-key', ''))\n", + "def custom(): return 'ok'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6ed4a731", + "metadata": {}, + "outputs": [], + "source": [ + "for i in range(2): test_eq(cli.get('/custom', headers={'x-api-key': 'abc'}).status_code, 200)\n", + "test_eq(cli.get('/custom', headers={'x-api-key': 'abc'}).status_code, 429)\n", + "test_eq(cli.get('/custom', headers={'x-api-key': 'xyz'}).status_code, 200)" + ] + }, + { + "cell_type": "markdown", + "id": "bf91ea45", + "metadata": {}, + "source": [ + "### Shared limits across routes:" + ] + }, + { + "cell_type": "markdown", + "id": "bd9e28c8", + "metadata": {}, + "source": [ + "Save the decorator and apply it to multiple routes to share a single bucket." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1fd5a249", + "metadata": {}, + "outputs": [], + "source": [ + "shared = limiter('2/m')\n", + "\n", + "@rt\n", + "@shared\n", + "def users(): return 'users'\n", + "\n", + "@rt\n", + "@shared\n", + "def posts(): return 'posts'\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42e03753", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(cli.get('/users').status_code, 200)\n", + "test_eq(cli.get('/posts').status_code, 200)\n", + "test_eq(cli.get('/users').status_code, 429)" + ] + }, + { + "cell_type": "markdown", + "id": "330b3cba", + "metadata": {}, + "source": [ + "# Export -" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "84300cc1", + "metadata": {}, + "outputs": [], + "source": [ + "#| hide\n", + "import nbdev; nbdev.nbdev_export()" + ] + } + ], + "metadata": { + "solveit_dialog_mode": "learning", + "solveit_ver": 2 + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nbs/api/10_magickey.ipynb b/nbs/api/10_magickey.ipynb new file mode 100644 index 00000000..cc011445 --- /dev/null +++ b/nbs/api/10_magickey.ipynb @@ -0,0 +1,588 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "c01b5481", + "metadata": {}, + "outputs": [], + "source": [ + "#| default_exp magickey" + ] + }, + { + "cell_type": "markdown", + "id": "a7f143ec", + "metadata": {}, + "source": [ + "# MagicKey\n", + "> Passwordless auth combining magic links and passkeys" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3fd43fe", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "import secrets, time\n", + "from json import loads\n", + "from urllib.parse import urlparse\n", + "\n", + "from webauthn import generate_authentication_options, generate_registration_options, verify_authentication_response, verify_registration_response, options_to_json\n", + "from webauthn.helpers import base64url_to_bytes, bytes_to_base64url\n", + "from webauthn.helpers.structs import AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement\n", + "\n", + "from fastcore.utils import *\n", + "from fastcore.xml import *\n", + "from fastlite import *\n", + "from fasthtml.basics import *\n", + "from fasthtml.starlette import *\n", + "from fasthtml.fastapp import *\n", + "from fasthtml.ratelimit import *" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cdf42c78", + "metadata": {}, + "outputs": [], + "source": [ + "from fastcore.test import *\n", + "from starlette.testclient import TestClient" + ] + }, + { + "cell_type": "markdown", + "id": "b7fca791", + "metadata": {}, + "source": [ + "## Helpers" + ] + }, + { + "cell_type": "markdown", + "id": "9c6b5bcc", + "metadata": {}, + "source": [ + "`public_origin` and `rp_id` are passed to `MagicKey.__init__` and used directly at both WebAuthn verification call sites — no helper needed.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "03d86e44", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "# _origin_kw removed; public_origin / rp_id inlined at call sites\n" + ] + }, + { + "cell_type": "markdown", + "id": "0d037d2c", + "metadata": {}, + "source": [ + "`_webauthn_js` builds an inline `