Files

226 lines
10 KiB
Python

"""Customer authentication and order scope, independent of staff authentication."""
import hashlib
import json
import secrets
import time
from flask import g, has_app_context, jsonify, request
from werkzeug.security import check_password_hash
from .config import get_setting
from .db import connect
from .visitor_service import valid_key, visitor_id
COOKIE_NAME = 'baiyue_customer'
SESSION_SECONDS = 8 * 3600
def demo_tenant(connection):
configured = get_setting('CUSTOMER_DEMO_TENANT_ID') or get_setting('ORDER_DATA_TENANT_ID')
if configured:
return configured
rows = connection.execute("SELECT DISTINCT tenant_id FROM order_test_data WHERE tenant_id<>'' LIMIT 2").fetchall()
return rows[0][0] if len(rows) == 1 else ''
def user_tenants(connection, row):
if row[4]:
return (row[4],)
rows = connection.execute('SELECT tenant_id FROM customer_tenant_grants WHERE customer_id=? ORDER BY tenant_id LIMIT 257',
(row[0],)).fetchall()
return tuple(item[0] for item in rows) if len(rows) <= 256 else ()
def seed_local_grants(connection, row):
if not row[6] or connection.execute('SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
return
if not row[4] and get_setting('CUSTOMER_DEMO_LOCAL_ORDERS') and not user_tenants(connection,row):
connection.execute('INSERT OR IGNORE INTO customer_tenant_grants(customer_id,tenant_id) '
"SELECT ?,tenant_id FROM order_test_data WHERE tenant_id<>'' GROUP BY tenant_id",(row[0],))
if user_tenants(connection,row):
connection.execute('UPDATE customer_users SET scope_initialized=1 WHERE id=?',(row[0],))
def scope_key(tenants):
return hashlib.sha256(json.dumps(sorted(tenants),ensure_ascii=False).encode()).hexdigest() if tenants else None
def effective_tenants(tenants):
configured = get_setting('ORDER_DATA_TENANT_ID')
return tuple(value for value in tenants if not configured or value == configured)
def current_scope_key():
from .visitor_service import customer_tenant_ids
return scope_key(customer_tenant_ids())
def init_customer_tables(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS customer_users (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, display_name TEXT NOT NULL,
tenant_id TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1,
is_demo INTEGER NOT NULL DEFAULT 0, scope_initialized INTEGER NOT NULL DEFAULT 0)''')
if 'scope_initialized' not in [item[1] for item in connection.execute('PRAGMA table_info(customer_users)')]:
connection.execute('ALTER TABLE customer_users ADD COLUMN scope_initialized INTEGER NOT NULL DEFAULT 0')
connection.execute('''CREATE TABLE IF NOT EXISTS customer_sessions (
token_hash TEXT PRIMARY KEY, customer_id INTEGER NOT NULL,
visitor_id TEXT NOT NULL, auth_stamp TEXT NOT NULL, expires_at INTEGER NOT NULL)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_customer_session_expiry ON customer_sessions(expires_at)')
connection.execute('''CREATE TABLE IF NOT EXISTS customer_tenant_grants (
customer_id INTEGER NOT NULL, tenant_id TEXT NOT NULL, PRIMARY KEY(customer_id,tenant_id))''')
if get_setting('CUSTOMER_DEMO_ENABLED'):
connection.execute('''INSERT OR IGNORE INTO customer_users
(username,password_hash,display_name,tenant_id,is_demo) VALUES(?,?,?,?,1)''',
(get_setting('CUSTOMER_DEMO_USERNAME'), get_setting('CUSTOMER_DEMO_PASSWORD_HASH'),
get_setting('CUSTOMER_DEMO_USERNAME'), demo_tenant(connection)))
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
'FROM customer_users WHERE username=?',(get_setting('CUSTOMER_DEMO_USERNAME'),)).fetchone()
seed_local_grants(connection,row)
def auth_stamp(row, tenants):
return hashlib.sha256((row[2] + '\n' + (scope_key(effective_tenants(tenants)) or '')).encode()).hexdigest()
def allowed_user(row):
return bool(row and row[5] and (not row[6] or (
get_setting('CUSTOMER_DEMO_ENABLED') and row[1] == get_setting('CUSTOMER_DEMO_USERNAME'))))
def attach_customer():
g.customer = None
g.customer_tenant_id = None
g.customer_tenant_ids = ()
token = request.cookies.get(COOKIE_NAME)
if not visitor_id() or not valid_key(token):
return
connection = connect()
try:
session = connection.execute('SELECT customer_id,auth_stamp FROM customer_sessions '
'WHERE token_hash=? AND visitor_id=? AND expires_at>?',
(hashlib.sha256(token.encode()).hexdigest(), visitor_id(), int(time.time()))).fetchone()
if not session:
return
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
'FROM customer_users WHERE id=?', (session[0],)).fetchone()
tenants = effective_tenants(user_tenants(connection,row)) if row else ()
if allowed_user(row) and secrets.compare_digest(auth_stamp(row,tenants), session[1]):
g.customer = {'id':row[0], 'username':row[1], 'name':row[3]}
g.customer_tenant_id = tenants[0] if len(tenants) == 1 else None
g.customer_tenant_ids = tenants
finally:
connection.close()
def current_customer_id():
customer = getattr(g, 'customer', None) if has_app_context() else None
return customer['id'] if customer else None
def bind_order_conversation(conv_id):
"""A conversation containing order facts also requires its customer login."""
if not conv_id:
return True
connection = connect()
try:
result = connection.execute('UPDATE chat_history SET customer_id=?,customer_scope=? WHERE id=? AND visitor_id=? '
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
(current_customer_id(),current_scope_key(),conv_id,visitor_id(),current_customer_id(),current_scope_key()))
connection.commit()
return result.rowcount == 1
finally:
connection.close()
def session_status():
return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
def order_login_response():
if current_customer_id():
return {'t':'当前账号尚未关联订单,请联系人工客服。'}
return {'t':'请先使用客户账号登录并关联订单,再查询订单或物流轨迹。','login_required':True}
def needs_customer_login(conv_id):
if current_customer_id():
return False
connection = connect()
try:
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
'AND customer_id IS NOT NULL',(conv_id,visitor_id())).fetchone())
finally:
connection.close()
def login(data, attempts, lock):
if not isinstance(data, dict):
return {'ok':False, 'error':'请输入账号和密码。'}, 400
username, password = data.get('username'), data.get('password')
if not isinstance(username, str) or not isinstance(password, str) or not 1 <= len(username) <= 80 or not 1 <= len(password) <= 256:
return {'ok':False, 'error':'请输入有效的账号和密码。'}, 400
now = int(time.time())
# A separate short failed-login budget supplements the global request limiter.
ip = request.remote_addr or 'unknown'
with lock:
failures = [stamp for stamp in attempts.get(ip, []) if stamp > now - 300]
if len(attempts) > 2048:
for key in list(attempts):
if not attempts[key] or attempts[key][-1] <= now - 300:
attempts.pop(key,None)
if len(failures) >= 5:
return {'ok':False, 'error':'登录尝试过多,请稍后重试。'}, 429
attempts[ip] = failures + [now]
connection = connect()
try:
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
'FROM customer_users WHERE username=?', (username.strip(),)).fetchone()
digest = row[2] if row else get_setting('CUSTOMER_DEMO_PASSWORD_HASH')
try:
valid = check_password_hash(digest, password)
except (ValueError, TypeError):
valid = False
if not valid or not allowed_user(row):
return {'ok':False, 'error':'账号或密码不正确。'}, 403
with lock:
attempts.pop(ip, None)
if row[6] and not row[4] and not user_tenants(connection,row) and not connection.execute(
'SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
tenant = demo_tenant(connection)
if tenant:
connection.execute("UPDATE customer_users SET tenant_id=? WHERE id=? AND tenant_id=''", (tenant,row[0]))
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
'FROM customer_users WHERE id=?', (row[0],)).fetchone()
seed_local_grants(connection,row)
old_token = request.cookies.get(COOKIE_NAME)
if valid_key(old_token):
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
(hashlib.sha256(old_token.encode()).hexdigest(), visitor_id()))
token = secrets.token_hex(32)
connection.execute('DELETE FROM customer_sessions WHERE expires_at<=?', (now,))
connection.execute('INSERT INTO customer_sessions VALUES(?,?,?,?,?)',
(hashlib.sha256(token.encode()).hexdigest(),row[0],visitor_id(),auth_stamp(row,user_tenants(connection,row)),now+SESSION_SECONDS))
connection.commit()
response = jsonify({'ok':True,'authenticated':True,'user':{'id':row[0],'username':row[1],'name':row[3]}})
response.set_cookie(COOKIE_NAME,token,max_age=SESSION_SECONDS,httponly=True,secure=request.is_secure,samesite='Lax',path='/')
return response
finally:
connection.close()
def logout():
token = request.cookies.get(COOKIE_NAME)
if valid_key(token):
connection = connect()
try:
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
(hashlib.sha256(token.encode()).hexdigest(),visitor_id()))
connection.commit()
finally:
connection.close()
response = jsonify({'ok':True,'authenticated':False,'user':None})
response.delete_cookie(COOKIE_NAME,path='/',secure=request.is_secure,httponly=True,samesite='Lax')
return response