96 lines
4.5 KiB
Python
96 lines
4.5 KiB
Python
"""Local customer passwords and revocable database sessions. No email service."""
|
|
import hashlib
|
|
import secrets
|
|
import logging
|
|
import json
|
|
from uuid import UUID, uuid4
|
|
from fastapi import HTTPException, Request
|
|
from .db import connect
|
|
|
|
def password_hash(password, salt=None):
|
|
salt = salt or secrets.token_hex(16)
|
|
digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5).hex()
|
|
return f'scrypt-v2${salt}${digest}'
|
|
|
|
def password_matches(password, stored):
|
|
try:
|
|
version, salt, digest = stored.split('$')
|
|
if version not in ('scrypt', 'scrypt-v2'): return False
|
|
actual = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8,
|
|
p=1 if version == 'scrypt' else 5).hex()
|
|
return secrets.compare_digest(actual, digest)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
DUMMY_PASSWORD_HASH = password_hash('invalid-account-password', '00'*16)
|
|
|
|
def session_row(request):
|
|
try:
|
|
sid = UUID(request.cookies.get('dtf_session', ''))
|
|
except ValueError:
|
|
raise HTTPException(401, 'Start a local session first')
|
|
with connect() as c:
|
|
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(401, 'Session expired; sign in or start a new session')
|
|
return row
|
|
|
|
def owner(request: Request):
|
|
return session_row(request)['owner']
|
|
|
|
def new_session(c, response, identity=None):
|
|
sid = uuid4()
|
|
identity = identity or uuid4()
|
|
c.execute('INSERT INTO dtf_local.sessions(id,owner) VALUES(%s,%s)', (sid,identity))
|
|
response.set_cookie('dtf_session', str(sid), httponly=True,
|
|
secure=os.environ.get('COOKIE_SECURE', 'false').lower() == 'true',
|
|
samesite='strict', max_age=86400*7)
|
|
return identity
|
|
|
|
def transfer_guest(c, previous, identity):
|
|
# Successful authentication can claim only the current guest browser's records.
|
|
if c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
|
|
return
|
|
for table in ('uploads','quotes','orders'):
|
|
c.execute(f'UPDATE dtf_local.{table} SET owner=%s WHERE owner=%s', (identity,previous['owner']))
|
|
c.execute('DELETE FROM dtf_local.sessions WHERE owner=%s', (previous['owner'],))
|
|
|
|
def audit(event, **fields):
|
|
# Only explicit metadata: never cookies, passwords, signed URLs or request bodies.
|
|
logging.getLogger('dtf.security').warning(json.dumps({'event':event, **fields}, sort_keys=True))
|
|
try:
|
|
from psycopg.types.json import Jsonb
|
|
with connect() as c:
|
|
c.execute('INSERT INTO dtf_local.security_events(event,details) VALUES(%s,%s)',(event,Jsonb(fields)))
|
|
except Exception:
|
|
# Logging must still work during a DB outage without recursively auditing itself.
|
|
logging.getLogger('dtf.security').error('Security event persistence unavailable')
|
|
|
|
def rate_limit(scope, identity, limit, seconds=900):
|
|
key = hashlib.sha256((scope+'|'+identity).encode()).hexdigest()
|
|
with connect() as c:
|
|
row = c.execute("""INSERT INTO dtf_local.login_attempts(key,attempts) VALUES(%s,1)
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
attempts=CASE WHEN dtf_local.login_attempts.started_at < now()-%s*interval '1 second' THEN 1 ELSE LEAST(dtf_local.login_attempts.attempts+1,1000000) END,
|
|
started_at=CASE WHEN dtf_local.login_attempts.started_at < now()-%s*interval '1 second' THEN now() ELSE dtf_local.login_attempts.started_at END
|
|
RETURNING attempts""", (key,seconds,seconds)).fetchone()
|
|
if row['attempts'] > limit:
|
|
audit('rate_limit', scope=scope)
|
|
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
|
|
|
|
def throttle(email, request):
|
|
# Independent account and source buckets prevent bypass by rotating emails.
|
|
rate_limit('auth-source', request.client.host if request.client else 'local', 60)
|
|
rate_limit('auth-account', email, 10)
|
|
|
|
def operator(request: Request):
|
|
token = request.cookies.get('dtf_operator', '')
|
|
if not token or len(token)>128:
|
|
raise HTTPException(401, 'Sign in to the local Kanban')
|
|
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
with connect() as c:
|
|
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(401, 'Operator session expired')
|
|
return row['username']
|