162 lines
7.5 KiB
Python
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
|