feat(auth): add TOTP 2FA setup and login verification
Some checks failed
CI / test (push) Failing after 21s
Some checks failed
CI / test (push) Failing after 21s
- add user twofa fields and migration - add 2FA setup/enable/disable endpoints - enforce OTP on login when 2FA enabled - add web login OTP field and settings UI
This commit is contained in:
44
app/utils/totp.py
Normal file
44
app/utils/totp.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
|
||||
def generate_totp_secret(length: int = 20) -> str:
|
||||
raw = secrets.token_bytes(length)
|
||||
return base64.b32encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def build_otpauth_uri(*, secret: str, account_name: str, issuer: str) -> str:
|
||||
label = urllib.parse.quote(f"{issuer}:{account_name}")
|
||||
issuer_q = urllib.parse.quote(issuer)
|
||||
secret_q = urllib.parse.quote(secret)
|
||||
return f"otpauth://totp/{label}?secret={secret_q}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30"
|
||||
|
||||
|
||||
def _totp_code(secret: str, for_time: int | None = None, step: int = 30, digits: int = 6) -> str:
|
||||
now = int(time.time()) if for_time is None else int(for_time)
|
||||
counter = now // step
|
||||
padded = secret + "=" * ((8 - len(secret) % 8) % 8)
|
||||
key = base64.b32decode(padded, casefold=True)
|
||||
msg = struct.pack(">Q", counter)
|
||||
digest = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0x0F
|
||||
binary = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
return str(binary % (10**digits)).zfill(digits)
|
||||
|
||||
|
||||
def verify_totp_code(secret: str, code: str, *, window: int = 1, step: int = 30, digits: int = 6) -> bool:
|
||||
prepared = "".join(ch for ch in code if ch.isdigit())
|
||||
if len(prepared) != digits:
|
||||
return False
|
||||
now = int(time.time())
|
||||
for delta in range(-window, window + 1):
|
||||
expected = _totp_code(secret, for_time=now + delta * step, step=step, digits=digits)
|
||||
if secrets.compare_digest(expected, prepared):
|
||||
return True
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user