Skip to content

Commit c241398

Browse files
committed
initial prototype of OL TOTP auth service
1 parent 8644d88 commit c241398

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

openlibrary/core/auth.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import hashlib
2+
import hmac
3+
import string
4+
import time
5+
import requests
6+
import socket, ssl, json
7+
from urllib.parse import urlparse
8+
from infogami import config
9+
from openlibrary.core import cache
10+
11+
class TimedOneTimePassword:
12+
13+
VALID_MINUTES = 10
14+
15+
@staticmethod
16+
def shorten(digest: str, length=6) -> str:
17+
"""
18+
Convert an HMAC digest (bytes) into a short alphanumeric code.
19+
"""
20+
alphabet = string.digits + string.ascii_uppercase
21+
num = int.from_bytes(digest, "big")
22+
base36 = ""
23+
while num > 0:
24+
num, i = divmod(num, 36)
25+
base36 = alphabet[i] + base36
26+
return base36[:length].lower()
27+
28+
@classmethod
29+
def generate(cls, service_ip:str, client_email: str, client_ip: str, ts:int|None = None) -> str:
30+
seed = config.get("otp_seed")
31+
ts = ts or int(time.time() // 60)
32+
payload = f"{service_ip}:{client_email}:{client_ip}:{ts}".encode()
33+
digest = hmac.new(seed.encode('utf-8'), payload, hashlib.sha256).digest()
34+
return cls.shorten(digest)
35+
36+
@staticmethod
37+
def verify_service(service_ip: str, service_url: str) -> bool:
38+
"""Doesn't work because of VPN"""
39+
parsed = urlparse(service_url)
40+
resolved_ip = socket.gethostbyname(parsed.hostname)
41+
r = requests.get(service_url, timeout=5)
42+
return (
43+
service_url.startswith("https://")
44+
and resolved_ip == service_ip
45+
and bool(r.json())
46+
)
47+
48+
@classmethod
49+
def is_ratelimited(cls, ttl=60, service_ip="", **kwargs):
50+
def ratelimit_error(key, ttl):
51+
return {
52+
"error": "ratelimit",
53+
"ratelimit": {
54+
"ttl": ttl,
55+
"key": key
56+
}
57+
}
58+
59+
mc = cache.get_memcache()
60+
# Limit requests to 1 / ttl per client
61+
for key, value in kwargs.items():
62+
cache_key = f"otp-client:{service_ip}:{key}:{value}"
63+
if not mc.add(cache_key, 1, expires=ttl):
64+
return ratelimit_error(cache_key, ttl)
65+
66+
# Limit globally to 3 attempts per email and ip per / ttl
67+
for key, value in kwargs.items():
68+
cache_key = f"otp-global:{key}:{value}"
69+
count = (mc.get(cache_key) or 0) + 1
70+
mc.set(cache_key, count, expires=ttl)
71+
if count > 3:
72+
return ratelimit_error(cache_key, ttl)
73+
74+
@classmethod
75+
def validate(cls, otp, service_ip, client_email, client_ip, ts):
76+
expected_otp = cls.generate(service_ip, client_email, client_ip, ts)
77+
return hmac.compare_digest(otp.lower(), expected_otp.lower())
78+
79+
@classmethod
80+
def is_valid(cls, client_email, client_ip, service_ip, otp):
81+
now_minute = int(time.time() // 60)
82+
for delta in range(cls.VALID_MINUTES):
83+
minute_ts = now_minute - delta
84+
if cls.validate(otp, service_ip, client_email, client_ip, minute_ts):
85+
return True
86+
return False

openlibrary/plugins/upstream/account.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
)
3434
from openlibrary.core import helpers as h
3535
from openlibrary.core import lending, stats
36+
from openlibrary.core.auth import TimedOneTimePassword as OTP
3637
from openlibrary.core.booknotes import Booknotes
3738
from openlibrary.core.bookshelves import Bookshelves
3839
from openlibrary.core.follows import PubSub
@@ -454,6 +455,59 @@ def POST(self):
454455
infogami_login().POST()
455456

456457

458+
class otp_service_issue(delegate.page):
459+
path = "/account/otp/issue"
460+
461+
def POST(self):
462+
"""
463+
>>> requests.get("https://staging.openlibrary.org/account/otp/issue", data={"email": "email@example.com", "ip": "127.0.0.1"})
464+
"""
465+
web.header('Content-Type', 'application/json')
466+
i = web.input(email="", ip="", challenge_url="", sendmail='true')
467+
required_keys = ("email", "ip", "service_ip")
468+
i.email = i.email.replace(" ", "+").lower()
469+
i.service_ip = web.ctx.env.get('HTTP_X_FORWARDED_FOR')
470+
if missing_fields := [k for k in required_keys if not getattr(i, k)]:
471+
return delegate.RawText(json.dumps({
472+
"error": "missing_keys",
473+
"missing_keys": missing_fields
474+
}))
475+
476+
# Challenge currently does not work due to Firewall/Proxy limitations
477+
if i.challenge_url and not OTP.verify_service(i.service_ip, i.challenge_url):
478+
return delegate.RawText(json.dumps({"error": "challenge_failed"}))
479+
if error := OTP.is_ratelimited(service_ip=i.service_ip, email=i.email, ip=i.ip):
480+
return delegate.RawText(json.dumps(error))
481+
482+
otp = OTP.generate(i.service_ip, i.email, i.ip)
483+
if i.sendmail.lower() == 'true':
484+
web.sendmail(
485+
config.from_address,
486+
i.email,
487+
subject="Your One Time Password",
488+
message=web.safestr(f"Your one time password is: {otp.upper()}"),
489+
)
490+
return delegate.RawText(json.dumps({"success": "issued"}))
491+
492+
class otp_service_redeem(delegate.page):
493+
path = "/account/otp/redeem"
494+
495+
def POST(self):
496+
web.header('Content-Type', 'application/json')
497+
required_keys = ("email", "ip", "service_ip", "otp")
498+
i = web.input(email="", ip="", otp="")
499+
i.email = i.email.replace(" ", "+").lower()
500+
i.service_ip = web.ctx.env.get('HTTP_X_FORWARDED_FOR')
501+
if missing_fields := [k for k in required_keys if not getattr(i, k)]:
502+
return delegate.RawText(json.dumps({
503+
"error": "missing_keys",
504+
"missing_keys": missing_fields
505+
}))
506+
if OTP.is_valid(i.email, i.ip, i.service_ip, i.otp):
507+
return delegate.RawText(json.dumps({"success": "redeemed"}))
508+
return delegate.RawText(json.dumps({"error": "otp_mismatch"}))
509+
510+
457511
class account_login(delegate.page):
458512
"""Account login.
459513

0 commit comments

Comments
 (0)