226 lines
10 KiB
Python
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
|