"""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