backend/auth.py (2740 bytes)
1 """Email sign-in links. 2 3 No passwords: a link is mailed, opening it signs the browser in. The link 4 carries a random token; only its hash is stored, it works once, and it expires. 5 """ 6 7 from __future__ import annotations 8 9 import hashlib 10 import secrets 11 from datetime import timedelta, timezone 12 13 from sqlalchemy import func 14 from sqlalchemy.orm import Session 15 16 from . import accounts 17 from .db import Account, LoginToken, utcnow 18 from .settings import settings 19 20 # Per address and per device, per hour. Enough for someone fumbling a typo, 21 # not enough to use us as a mail cannon. 22 _MAX_LINKS_PER_HOUR = 5 23 24 25 class TooManyRequests(RuntimeError): 26 pass 27 28 29 def _hash(token: str) -> str: 30 return hashlib.sha256(token.encode("utf-8")).hexdigest() 31 32 33 def issue(session: Session, account: Account, email: str) -> str: 34 """Create a sign-in token for `email` and return the link to send.""" 35 since = utcnow() - timedelta(hours=1) 36 recent = ( 37 session.query(func.count(LoginToken.id)) 38 .filter( 39 LoginToken.created_at >= since, 40 (LoginToken.email == email) | (LoginToken.account_id == account.id), 41 ) 42 .scalar() 43 or 0 44 ) 45 if recent >= _MAX_LINKS_PER_HOUR: 46 raise TooManyRequests( 47 "Too many sign-in links requested. Wait a little and try again." 48 ) 49 50 token = secrets.token_urlsafe(32) 51 session.add( 52 LoginToken( 53 token_hash=_hash(token), 54 email=email, 55 account_id=account.id, 56 expires_at=utcnow() + timedelta(minutes=settings.login_link_minutes), 57 ) 58 ) 59 session.commit() 60 # Lands on the page, which then POSTs the token back. Mail scanners follow 61 # links but do not run scripts, so they cannot burn a one-time token. 62 return f"{settings.public_base_url.rstrip('/')}/?login={token}" 63 64 65 def redeem(session: Session, token: str, device: Account) -> Account | None: 66 """Spend a token. Returns the account to sign this browser into.""" 67 row = ( 68 session.query(LoginToken) 69 .filter(LoginToken.token_hash == _hash(token or "")) 70 .first() 71 ) 72 if row is None or row.used_at is not None: 73 return None 74 expires = row.expires_at 75 if expires.tzinfo is None: # SQLite hands back naive datetimes 76 expires = expires.replace(tzinfo=timezone.utc) 77 if expires < utcnow(): 78 return None 79 80 row.used_at = utcnow() 81 # Fold in the device that opened the link. Usually that is the one that 82 # asked for it; when it is not (link opened on a phone), this still does 83 # the right thing - the phone's anonymous work joins the account. 84 owner = accounts.adopt_email(session, device, row.email) 85 session.commit() 86 return owner