Files
baiyue-logistics-chat/logistics_chat/staff_service.py
T
2026-11-02 17:51:10 +08:00

162 lines
7.5 KiB
Python

"""Independent, revocable staff sessions; only token digests are persisted."""
import hashlib
from hmac import compare_digest
import secrets
import time
from flask import current_app, g, jsonify, redirect, request
from werkzeug.security import check_password_hash
from .db import connect
COOKIE_NAME = 'baiyue_staff'
SESSION_SECONDS = 24 * 3600
PUBLIC_ENDPOINTS = frozenset({
'static', 'pages.index', 'pages.health', 'admin.admin', 'admin.admin_login',
'chat.stream_chat', 'chat.feedback', 'history.new_chat', 'history.get_history',
'history.get_messages', 'history.del_history', 'history.clear_all',
'files.upload_file', 'files.uploaded_file', 'business.add_ticket', 'business.rate_chat',
'visitor.visitor_session', 'visitor.visitor_human', 'customer.session',
'customer.login', 'customer.logout', 'speech.speech_config', 'speech.speech_transcribe',
})
def digest(value):
return hashlib.sha256(value.encode('utf-8')).hexdigest()
def init_staff_tables(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(staff_sessions)')}
required = {'token_hash', 'username', 'auth_stamp', 'csrf_token', 'expires_at'}
if columns and not required.issubset(columns):
# Older versions keyed sessions by agent_id. Revoke incompatible credentials
# and rebuild this disposable auth table; never alter accounts/business data.
connection.execute('DROP TABLE staff_sessions')
connection.execute('''CREATE TABLE IF NOT EXISTS staff_sessions (
token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, auth_stamp TEXT NOT NULL,
csrf_token TEXT NOT NULL, expires_at REAL NOT NULL
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_staff_expiry ON staff_sessions(expires_at)')
# Legacy unlimited sessions have no issue time; require a fresh bounded login.
# Leave valid deadlines untouched so restarts never extend a session.
connection.execute('DELETE FROM staff_sessions WHERE expires_at<=?', (time.time(),))
# Recreating a username or restoring an earlier password must not revive cookies.
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_delete_agent
AFTER DELETE ON agents BEGIN
DELETE FROM staff_sessions WHERE username=OLD.username;
END''')
# SQLite REPLACE may skip DELETE triggers; INSERT still fires in every connection.
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_insert_agent
AFTER INSERT ON agents BEGIN
DELETE FROM staff_sessions WHERE username=NEW.username;
END''')
connection.execute('''CREATE TRIGGER IF NOT EXISTS staff_sessions_change_credentials
AFTER UPDATE OF username,password ON agents
WHEN OLD.username IS NOT NEW.username OR OLD.password IS NOT NEW.password
BEGIN DELETE FROM staff_sessions WHERE username=OLD.username; END''')
def token_hash():
token = request.cookies.get(COOKIE_NAME, '')
return digest(token) if len(token) == 64 and all(c in '0123456789abcdef' for c in token) else None
def same_origin():
origin = request.headers.get('Origin')
return (not origin or origin.rstrip('/') == request.host_url.rstrip('/')) and request.headers.get('Sec-Fetch-Site') != 'cross-site'
def attach_staff():
"""New/unknown endpoints are staff-only until explicitly reviewed as public."""
g.staff = None
if request.endpoint in PUBLIC_ENDPOINTS:
return None
hashed = token_hash()
if hashed:
connection = connect()
try:
row = connection.execute(
'SELECT s.username,a.name,s.csrf_token,s.auth_stamp,a.password '
'FROM staff_sessions s JOIN agents a ON a.username=s.username '
'WHERE s.token_hash=? AND s.expires_at>?', (hashed, time.time())).fetchone()
finally:
connection.close()
if row and compare_digest(row[3], digest(row[4])):
g.staff = {'username': row[0], 'name': row[1], 'csrf_token': row[2]}
if not g.staff:
if request.path.startswith('/admin/') and request.method in ('GET', 'HEAD'):
return redirect('/admin')
return jsonify({'ok': False, 'error': 'staff_login_required', 'e': '后台登录已失效,请重新登录。'}), 401
if request.method not in ('GET', 'HEAD', 'OPTIONS'):
csrf = request.headers.get('X-CSRF-Token', '')
if not same_origin() or not compare_digest(csrf.encode('utf-8'), g.staff['csrf_token'].encode('utf-8')):
return jsonify({'ok': False, 'error': 'csrf_invalid', 'e': '请求校验失败,请刷新后台页面。'}), 403
def login(data):
if not same_origin():
return {'ok': False}, 403
if not request.is_json:
return {'ok': False}, 415
if not isinstance(data, dict):
return {'ok': False}, 401
username, password = data.get('username'), data.get('password')
if not isinstance(username, str) or not isinstance(password, str) or not 0 < len(username) <= 256 or not 0 < len(password) <= 1024:
return {'ok': False}, 401
attempts = current_app.extensions['staff_login_attempts']
lock = current_app.extensions['staff_login_lock']
# Proxy traffic can share a peer IP. Failures for one account must not lock all staff.
attempt_key = (request.remote_addr or 'unknown', username)
now = time.time()
# Reserve attempts before expensive password hashing, including concurrent requests.
with lock:
for key in list(attempts):
attempts[key] = [instant for instant in attempts[key] if now - instant < 300]
if not attempts[key]:
del attempts[key]
if len(attempts.get(attempt_key, [])) >= 5:
return {'ok': False, 'e': '登录尝试过多,请 5 分钟后重试。'}, 429, {'Retry-After': '300'}
attempts.setdefault(attempt_key, []).append(now)
connection = connect()
try:
row = connection.execute('SELECT name,password FROM agents WHERE username=?', (username,)).fetchone()
valid = False
if row and isinstance(row[1], str) and row[1]:
if row[1].startswith(('pbkdf2:', 'scrypt:')):
try:
valid = check_password_hash(row[1], password)
except (ValueError, TypeError):
pass
else:
valid = compare_digest(row[1].encode('utf-8'), password.encode('utf-8'))
if not valid:
return {'ok': False}, 401
token, csrf = secrets.token_hex(32), secrets.token_hex(32)
connection.execute('DELETE FROM staff_sessions WHERE expires_at<=? OR token_hash=?', (now, token_hash()))
connection.execute('INSERT INTO staff_sessions VALUES(?,?,?,?,?)',
(digest(token), username, digest(row[1]), csrf, now + SESSION_SECONDS))
connection.commit()
finally:
connection.close()
with lock:
attempts.pop(attempt_key, None)
response = jsonify({'ok': True, 'name': row[0]})
response.set_cookie(COOKIE_NAME, token, max_age=SESSION_SECONDS, httponly=True,
secure=request.is_secure, samesite='Lax', path='/')
response.headers['Cache-Control'] = 'no-store'
return response
def logout():
connection = connect()
try:
connection.execute('DELETE FROM staff_sessions WHERE token_hash=?', (token_hash(),))
connection.commit()
finally:
connection.close()
response = jsonify({'ok': True})
response.delete_cookie(COOKIE_NAME, path='/', httponly=True, secure=request.is_secure, samesite='Lax')
response.headers['Cache-Control'] = 'no-store'
return response