Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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