#!/usr/bin/env python3
"""
设备白名单数据库模块
- 设备标识用浏览器生成的 UUID + 简单指纹
- 白名单只通过微信/阿里云命令行授权
- 无任何登录界面
"""
import sqlite3
import os
import json
from datetime import datetime
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'device_auth.db')
# ============ 资源分类 ============
# 每个类别路径前缀 + 说明
RESOURCE_TYPES = {
'public': {'paths': ['/notes/'], 'desc': '公开知识库'},
'doc': {'paths': ['/doc/'], 'desc': '个人文档'},
'private': {'paths': ['/private/'], 'desc': '私有知识库'},
'webchat': {'paths': ['/webchat/'], 'desc': '网页聊天'},
'mian': {'paths': ['/child-mian/'], 'desc': '阿棉的聊天'},
'liangliang':{'paths': ['/child-liangliang/'], 'desc': '亮亮的聊天'},
'world': {'paths': ['/world/'], 'desc': '世界监控'},
'admin': {'paths': ['/chat/', '/monitor/', '/hermes/'], 'desc': '管理后台'},
'api': {'paths': ['/api/', '/ask'], 'desc': 'API 接口'},
}
# 反向映射:路径前缀 → 资源类型
PATH_TO_TYPE = {}
for tname, tinfo in RESOURCE_TYPES.items():
for p in tinfo['paths']:
PATH_TO_TYPE[p] = tname
def resolve_type(path):
"""根据请求路径返回资源类型名称"""
for prefix, tname in sorted(PATH_TO_TYPE.items(), key=lambda x: -len(x[0])):
if path.startswith(prefix):
return tname
return None
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS device_whitelist (
device_id TEXT PRIMARY KEY,
fingerprint TEXT DEFAULT '',
label TEXT DEFAULT '',
allowed_types TEXT DEFAULT '*', -- 逗号分隔,* 表示全部
created_at TEXT NOT NULL,
authorized_at TEXT,
authorized_by TEXT DEFAULT '', -- 'wechat' 或 'terminal'
last_seen_at TEXT,
last_seen_path TEXT DEFAULT ''
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS pending_auth (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
fingerprint TEXT DEFAULT '',
user_agent TEXT DEFAULT '',
ip TEXT DEFAULT '',
requested_path TEXT DEFAULT '',
resource_type TEXT DEFAULT '',
notified INTEGER DEFAULT 0, -- 是否已推送到微信
created_at TEXT NOT NULL
)
""")
conn.commit()
conn.close()
def device_exists(device_id):
conn = sqlite3.connect(DB_PATH)
row = conn.execute("SELECT 1 FROM device_whitelist WHERE device_id=?", (device_id,)).fetchone()
conn.close()
return row is not None
def is_device_allowed(device_id, resource_type):
"""检查设备是否有权访问某类资源"""
conn = sqlite3.connect(DB_PATH)
row = conn.execute("SELECT allowed_types FROM device_whitelist WHERE device_id=?", (device_id,)).fetchone()
conn.close()
if not row:
return False
allowed = row[0]
if allowed == '*':
return True
types = [t.strip() for t in allowed.split(',')]
return resource_type in types
def record_device(device_id, fingerprint, user_agent, ip, path):
"""记录新设备访问,返回是否是新设备(之前未见过的)"""
conn = sqlite3.connect(DB_PATH)
# 更新 last_seen
now = datetime.now().isoformat()
conn.execute("""
UPDATE device_whitelist SET
last_seen_at=?, last_seen_path=?,
fingerprint=CASE WHEN fingerprint='' THEN ? ELSE fingerprint END
WHERE device_id=?
""", (now, path, fingerprint, device_id))
row = conn.execute("SELECT 1 FROM device_whitelist WHERE device_id=?", (device_id,)).fetchone()
if row:
# 已有设备,检查此次请求的资源类型是否被允许
conn.close()
return False # 不是新设备
# 新设备:加入 pending
rtype = resolve_type(path) or 'unknown'
conn.execute("""
INSERT INTO pending_auth (device_id, fingerprint, user_agent, ip, requested_path, resource_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (device_id, fingerprint, user_agent[:200], ip, path, rtype, now))
conn.commit()
conn.close()
return True # 是新设备
def get_pending_list(limit=10):
"""获取待授权的设备列表"""
conn = sqlite3.connect(DB_PATH)
rows = conn.execute("""
SELECT id, device_id, fingerprint, user_agent, ip, requested_path, resource_type, created_at
FROM pending_auth WHERE notified=0 ORDER BY id DESC LIMIT ?
""", (limit,)).fetchall()
conn.close()
return [
{
'id': r[0], 'device_id': r[1], 'fingerprint': r[2],
'user_agent': r[3], 'ip': r[4], 'path': r[5],
'resource_type': r[6], 'created_at': r[7]
}
for r in rows
]
def authorize_device(device_id, label='', allowed_types='*', authorized_by='wechat'):
"""授权设备,同时从 pending 移除"""
conn = sqlite3.connect(DB_PATH)
now = datetime.now().isoformat()
conn.execute("""
INSERT OR REPLACE INTO device_whitelist
(device_id, label, allowed_types, created_at, authorized_at, authorized_by)
VALUES (?, ?, ?, COALESCE((SELECT created_at FROM device_whitelist WHERE device_id=?), ?), ?, ?)
""", (device_id, label, allowed_types, device_id, now, now, authorized_by))
conn.execute("DELETE FROM pending_auth WHERE device_id=?", (device_id,))
conn.commit()
conn.close()
def revoke_device(device_id):
conn = sqlite3.connect(DB_PATH)
conn.execute("DELETE FROM device_whitelist WHERE device_id=?", (device_id,))
conn.execute("DELETE FROM pending_auth WHERE device_id=?", (device_id,))
conn.commit()
conn.close()
def list_devices():
conn = sqlite3.connect(DB_PATH)
rows = conn.execute("""
SELECT device_id, label, allowed_types, authorized_at, authorized_by, last_seen_at, last_seen_path
FROM device_whitelist ORDER BY authorized_at DESC
""").fetchall()
conn.close()
return [
{'device_id': r[0], 'label': r[1], 'allowed_types': r[2],
'authorized_at': r[3], 'authorized_by': r[4],
'last_seen_at': r[5], 'last_seen_path': r[6]}
for r in rows
]
def mark_notified(pending_id):
conn = sqlite3.connect(DB_PATH)
conn.execute("UPDATE pending_auth SET notified=1 WHERE id=?", (pending_id,))
conn.commit()
conn.close()
# ============ 设备画像 ============
# fingerprint_profile 存储前端采集的详细设备信息,指纹哈希作为主键
# profile 字段是 JSON,包含操作系统、浏览器、GPU、屏幕、时区、语言等
# description 字段是人类可读的设备描述(如 "华为 Mate 60 Pro · 上海")
def get_or_create_fingerprint(fingerprint, profile, device_id=None):
"""根据指纹查找设备;如果指纹不存在则创建记录,返回 device_id"""
conn = sqlite3.connect(DB_PATH)
# 查找已有的指纹
row = conn.execute(
"SELECT device_id, profile FROM device_fingerprints WHERE fingerprint=?",
(fingerprint,)
).fetchone()
if row:
# 已有此指纹 → 更新 profile(如果 device_id 变了就覆盖)
existing_did = row[0]
if device_id and device_id != existing_did:
# 同一个指纹出现了新的 device_id → 用现有的 device_id
pass # 不改变,保持首次绑定的 device_id
# 更新 profile 和 last_seen
conn.execute(
"UPDATE device_fingerprints SET profile=?, last_seen_at=datetime('now') WHERE fingerprint=?",
(json.dumps(profile, ensure_ascii=False), fingerprint)
)
conn.commit()
conn.close()
return existing_did, False # False = 不是新设备
# 新指纹
if not device_id:
import uuid
device_id = str(uuid.uuid4())
# 生成人类可读描述
desc = generate_device_description(profile)
conn.execute("""
INSERT INTO device_fingerprints (fingerprint, device_id, profile, description, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
""", (fingerprint, device_id, json.dumps(profile, ensure_ascii=False), desc))
conn.commit()
conn.close()
return device_id, True # True = 新设备
def find_device_by_fingerprint(fingerprint):
"""通过指纹查找已授权的 device_id"""
conn = sqlite3.connect(DB_PATH)
row = conn.execute("""
SELECT w.device_id, f.description
FROM device_fingerprints f
JOIN device_whitelist w ON f.device_id = w.device_id
WHERE f.fingerprint=?
""", (fingerprint,)).fetchone()
conn.close()
if row:
return {'device_id': row[0], 'description': row[1]}
return None
def find_fingerprint_by_device_id(device_id):
"""通过 device_id 查找对应的指纹和描述"""
conn = sqlite3.connect(DB_PATH)
row = conn.execute(
"SELECT fingerprint, profile, description FROM device_fingerprints WHERE device_id=? ORDER BY last_seen_at DESC LIMIT 1",
(device_id,)
).fetchone()
conn.close()
if row:
try:
profile = json.loads(row[1]) if row[1] else {}
except:
profile = {}
return {'fingerprint': row[0], 'profile': profile, 'description': row[2]}
return None
def generate_device_description(profile):
"""根据设备画像生成人类可读的设备描述"""
parts = []
# 设备型号
device_model = ''
if profile.get('sec_ch_ua_model'):
device_model = profile['sec_ch_ua_model']
elif profile.get('deviceBrand') and profile.get('deviceModel'):
device_model = f"{profile['deviceBrand']} {profile['deviceModel']}"
if device_model:
parts.append(device_model)
# 操作系统
os_info = profile.get('os', '')
if not os_info and profile.get('sec_ch_ua_platform'):
os_info = profile['sec_ch_ua_platform']
if os_info:
parts.append(os_info)
# 地理位置(IP 归属地)
if profile.get('ip_location'):
parts.append(profile['ip_location'])
# 设备特征
features = []
if profile.get('touchDevice'):
features.append('📱触控屏')
if profile.get('maxTouchPoints', 0) > 0:
pass # 触控信息通过 emoji 表达
if profile.get('cookiesEnabled') == False:
features.append('🚫Cookie禁用')
if features:
parts.extend(features)
if parts:
return ' · '.join(parts)
return '未知设备'
def get_fingerprint_descriptions():
"""获取所有指纹记录的设备画像(用于微信推送时展示)"""
conn = sqlite3.connect(DB_PATH)
rows = conn.execute("""
SELECT f.fingerprint, f.device_id, f.description, f.profile, f.last_seen_at,
CASE WHEN w.device_id IS NOT NULL THEN 1 ELSE 0 END as authorized
FROM device_fingerprints f
LEFT JOIN device_whitelist w ON f.device_id = w.device_id
ORDER BY f.last_seen_at DESC
""").fetchall()
conn.close()
result = []
for r in rows:
try:
profile = json.loads(r[3]) if r[3] else {}
except:
profile = {}
result.append({
'fingerprint': r[0][:16] + '…' if len(r[0]) > 16 else r[0],
'device_id': r[1],
'description': r[2],
'authorized': bool(r[5]),
'last_seen_at': r[4],
'detail': profile,
})
return result
# ============ 黑名单 ============
def is_blacklisted(device_id):
"""检查设备是否在黑名单中"""
conn = sqlite3.connect(DB_PATH)
row = conn.execute("SELECT 1 FROM blacklist WHERE device_id=?", (device_id,)).fetchone()
conn.close()
return row is not None
def blacklist_device(device_id, reason=''):
conn = sqlite3.connect(DB_PATH)
conn.execute("INSERT OR IGNORE INTO blacklist (device_id, reason) VALUES (?, ?)", (device_id, reason))
conn.commit()
conn.close()
def unblacklist_device(device_id):
conn = sqlite3.connect(DB_PATH)
conn.execute("DELETE FROM blacklist WHERE device_id=?", (device_id,))
conn.commit()
conn.close()
def list_blacklist():
conn = sqlite3.connect(DB_PATH)
rows = conn.execute("SELECT device_id, reason, created_at FROM blacklist ORDER BY created_at DESC").fetchall()
conn.close()
return rows
# 初始化(确保 blacklist 表存在)
_init_conn = sqlite3.connect(DB_PATH)
_init_conn.execute("""
CREATE TABLE IF NOT EXISTS blacklist (
device_id TEXT PRIMARY KEY,
reason TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
_init_conn.execute("""
CREATE TABLE IF NOT EXISTS device_fingerprints (
fingerprint TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
profile TEXT DEFAULT '{}',
description TEXT DEFAULT '',
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
)
""")
_init_conn.execute("""
CREATE INDEX IF NOT EXISTS idx_fingerprints_device_id ON device_fingerprints(device_id)
""")
_init_conn.commit()
_init_conn.close()
# 初始化
init_db()
if __name__ == '__main__':
# 测试
print("设备白名单数据库已初始化")
print(f"数据库路径: {DB_PATH}")
print(f"资源分类: {', '.join(RESOURCE_TYPES.keys())}")