Skip to content

Commit dd3ddb2

Browse files
authored
TOTP auth (#11288)
TOTP Service * docs(ai): add OTP service subsystem doc; fix broken doctest - Move OTP/TOTP docs into docs/ai/subsystems/otp_service.md - Remove network-dependent doctest from otp_service_issue.POST (test env blocks network requests; doctest had no assertions anyway)
1 parent 8a1916d commit dd3ddb2

File tree

3 files changed

+197
-0
lines changed

3 files changed

+197
-0
lines changed

docs/ai/subsystems/otp_service.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# OTP Auth Service
2+
3+
Open Library acts as a TOTP (Timed One-Time Password) provider for Lenny, the Internet Archive book-lending service. Lenny requests an OTP, Open Library generates one and emails it to the patron, and the patron enters it in Lenny, which forwards it back to Open Library for verification.
4+
5+
**Relevant files:**
6+
- `openlibrary/core/auth.py``TimedOneTimePassword` class (generate, validate, rate-limit)
7+
- `openlibrary/plugins/upstream/account.py``otp_service_issue` and `otp_service_redeem` endpoints
8+
9+
## Endpoints
10+
11+
| Method | Path | Purpose |
12+
|--------|------|---------|
13+
| POST | `/account/otp/issue` | Generate and email an OTP to a patron |
14+
| POST | `/account/otp/redeem` | Verify an OTP submitted by a patron |
15+
16+
Both endpoints read `service_ip` from the `X-Forwarded-For` request header (set automatically by nginx in production/docker). They return JSON.
17+
18+
## Local Docker Testing
19+
20+
**1. Add `otp_seed` to the dev config** (`conf/openlibrary.yml`):
21+
22+
```yaml
23+
otp_seed: "dev-secret-seed"
24+
```
25+
26+
**2. Start the stack:**
27+
28+
```bash
29+
docker compose up
30+
```
31+
32+
**3. Issue an OTP** (pass `sendmail=false` to skip SMTP in local dev):
33+
34+
```bash
35+
curl -s -X POST http://localhost:8080/account/otp/issue \
36+
-H 'X-Forwarded-For: 1.2.3.4' \
37+
-d 'email=patron@example.com&ip=5.6.7.8&sendmail=false'
38+
# → {"success": "issued"}
39+
```
40+
41+
**4. Compute the expected OTP** (since email is not sent locally):
42+
43+
```bash
44+
docker compose exec web python3 - <<'EOF'
45+
from openlibrary.core.auth import TimedOneTimePassword as OTP
46+
# Use the same service_ip, email, ip as the issue request
47+
print(OTP.generate("1.2.3.4", "patron@example.com", "5.6.7.8"))
48+
EOF
49+
```
50+
51+
**5. Redeem the OTP:**
52+
53+
```bash
54+
curl -s -X POST http://localhost:8080/account/otp/redeem \
55+
-H 'X-Forwarded-For: 1.2.3.4' \
56+
-d 'email=patron@example.com&ip=5.6.7.8&otp=<OTP_FROM_STEP_4>'
57+
# → {"success": "redeemed"}
58+
```
59+
60+
## Notes
61+
62+
- `service_ip` (from `X-Forwarded-For`) must match between issue and redeem requests.
63+
- OTPs are valid for `VALID_MINUTES = 10` minutes (checked across rolling 1-minute windows).
64+
- Rate limiting uses memcache: 1 request per TTL per client, max 3 attempts per email/ip globally.
65+
- `verify_service` (challenge URL verification) is intentionally disabled — Open Library cannot make outbound requests to verify endpoints due to WAF/proxy restrictions.

openlibrary/core/auth.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import datetime
22
import hashlib
33
import hmac
4+
import socket
5+
import string
46
import time
7+
from urllib.parse import urlparse
8+
9+
import requests
510

611
from infogami import config
12+
from openlibrary.core import cache
713

814

915
class ExpiredTokenError(Exception):
@@ -93,3 +99,79 @@ def verify(
9399
if err:
94100
raise err
95101
return result
102+
103+
104+
class TimedOneTimePassword:
105+
106+
VALID_MINUTES = 10
107+
108+
@staticmethod
109+
def shorten(digest: bytes, length=6) -> str:
110+
"""
111+
Convert an HMAC digest (bytes) into a short alphanumeric code.
112+
"""
113+
alphabet = string.digits + string.ascii_uppercase
114+
num = int.from_bytes(digest, "big")
115+
base36 = ""
116+
while num > 0:
117+
num, i = divmod(num, 36)
118+
base36 = alphabet[i] + base36
119+
return base36[:length].lower()
120+
121+
@classmethod
122+
def generate(
123+
cls, service_ip: str, client_email: str, client_ip: str, ts: int | None = None
124+
) -> str:
125+
seed = config.get("otp_seed")
126+
ts = ts or int(time.time() // 60)
127+
payload = f"{service_ip}:{client_email}:{client_ip}:{ts}".encode()
128+
digest = hmac.new(seed.encode('utf-8'), payload, hashlib.sha256).digest()
129+
return cls.shorten(digest)
130+
131+
@staticmethod
132+
def verify_service(service_ip: str, service_url: str) -> bool:
133+
"""Doesn't work because of VPN"""
134+
parsed = urlparse(service_url)
135+
if not parsed.hostname:
136+
return False
137+
resolved_ip = socket.gethostbyname(parsed.hostname)
138+
r = requests.get(service_url, timeout=5)
139+
return (
140+
service_url.startswith("https://")
141+
and resolved_ip == service_ip
142+
and bool(r.json())
143+
)
144+
145+
@classmethod
146+
def is_ratelimited(cls, ttl=60, service_ip="", **kwargs):
147+
def ratelimit_error(key, ttl):
148+
return {"error": "ratelimit", "ratelimit": {"ttl": ttl, "key": key}}
149+
150+
mc = cache.get_memcache()
151+
# Limit requests to 1 / ttl per client
152+
for key, value in kwargs.items():
153+
cache_key = f"otp-client:{service_ip}:{key}:{value}"
154+
if not mc.add(cache_key, 1, expires=ttl):
155+
return ratelimit_error(cache_key, ttl)
156+
157+
# Limit globally to 3 attempts per email and ip per / ttl
158+
for key, value in kwargs.items():
159+
cache_key = f"otp-global:{key}:{value}"
160+
count = (mc.get(cache_key) or 0) + 1
161+
mc.set(cache_key, count, expires=ttl)
162+
if count > 3:
163+
return ratelimit_error(cache_key, ttl)
164+
165+
@classmethod
166+
def validate(cls, otp, service_ip, client_email, client_ip, ts):
167+
expected_otp = cls.generate(service_ip, client_email, client_ip, ts)
168+
return hmac.compare_digest(otp.lower(), expected_otp.lower())
169+
170+
@classmethod
171+
def is_valid(cls, client_email, client_ip, service_ip, otp):
172+
now_minute = int(time.time() // 60)
173+
for delta in range(cls.VALID_MINUTES):
174+
minute_ts = now_minute - delta
175+
if cls.validate(otp, service_ip, client_email, client_ip, minute_ts):
176+
return True
177+
return False

openlibrary/plugins/upstream/account.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from openlibrary.core import helpers as h
3535
from openlibrary.core import lending, stats
3636
from openlibrary.core.auth import ExpiredTokenError, HMACToken, MissingKeyError
37+
from openlibrary.core.auth import TimedOneTimePassword as OTP
3738
from openlibrary.core.booknotes import Booknotes
3839
from openlibrary.core.bookshelves import Bookshelves
3940
from openlibrary.core.follows import PubSub
@@ -445,6 +446,55 @@ def POST(self):
445446
infogami_login().POST()
446447

447448

449+
class otp_service_issue(delegate.page):
450+
path = "/account/otp/issue"
451+
452+
def POST(self):
453+
web.header('Content-Type', 'application/json')
454+
i = web.input(email="", ip="", challenge_url="", sendmail='true')
455+
required_keys = ("email", "ip", "service_ip")
456+
i.email = i.email.replace(" ", "+").lower()
457+
i.service_ip = web.ctx.env.get('HTTP_X_FORWARDED_FOR')
458+
if missing_fields := [k for k in required_keys if not getattr(i, k)]:
459+
return delegate.RawText(
460+
json.dumps({"error": "missing_keys", "missing_keys": missing_fields})
461+
)
462+
463+
# Challenge currently does not work due to Firewall/Proxy limitations
464+
if i.challenge_url and not OTP.verify_service(i.service_ip, i.challenge_url):
465+
return delegate.RawText(json.dumps({"error": "challenge_failed"}))
466+
if error := OTP.is_ratelimited(service_ip=i.service_ip, email=i.email, ip=i.ip):
467+
return delegate.RawText(json.dumps(error))
468+
469+
otp = OTP.generate(i.service_ip, i.email, i.ip)
470+
if i.sendmail.lower() == 'true':
471+
web.sendmail(
472+
config.from_address,
473+
i.email,
474+
subject="Your One Time Password",
475+
message=web.safestr(f"Your one time password is: {otp.upper()}"),
476+
)
477+
return delegate.RawText(json.dumps({"success": "issued"}))
478+
479+
480+
class otp_service_redeem(delegate.page):
481+
path = "/account/otp/redeem"
482+
483+
def POST(self):
484+
web.header('Content-Type', 'application/json')
485+
required_keys = ("email", "ip", "service_ip", "otp")
486+
i = web.input(email="", ip="", otp="")
487+
i.email = i.email.replace(" ", "+").lower()
488+
i.service_ip = web.ctx.env.get('HTTP_X_FORWARDED_FOR')
489+
if missing_fields := [k for k in required_keys if not getattr(i, k)]:
490+
return delegate.RawText(
491+
json.dumps({"error": "missing_keys", "missing_keys": missing_fields})
492+
)
493+
if OTP.is_valid(i.email, i.ip, i.service_ip, i.otp):
494+
return delegate.RawText(json.dumps({"success": "redeemed"}))
495+
return delegate.RawText(json.dumps({"error": "otp_mismatch"}))
496+
497+
448498
class account_login(delegate.page):
449499
"""Account login.
450500

0 commit comments

Comments
 (0)