Add personal accounts, scoped access and editable family genealogy
This commit is contained in:
@@ -27,6 +27,8 @@ import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import onvif
|
||||
import playback
|
||||
import identity
|
||||
import genealogy
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
os.umask(0o077)
|
||||
@@ -66,6 +68,10 @@ class Problem(Exception):
|
||||
self.status = status
|
||||
|
||||
|
||||
ACCOUNTS = identity.Accounts(sys.modules[__name__])
|
||||
FAMILY = genealogy.Family(sys.modules[__name__])
|
||||
|
||||
|
||||
def setting(key, default=None):
|
||||
with LOCK:
|
||||
row = DB.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone()
|
||||
@@ -470,15 +476,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def authenticated(self):
|
||||
token = self.token()
|
||||
if not re.fullmatch('[a-f0-9]{64}', token):
|
||||
return False
|
||||
return None
|
||||
with LOCK:
|
||||
return bool(DB.execute('SELECT 1 FROM sessions WHERE hash=? AND expires>?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone())
|
||||
row = DB.execute('SELECT user_id FROM sessions WHERE hash=? AND expires>?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone()
|
||||
user = get_object('users', row['user_id']) if row else None
|
||||
return user if user and not user['disabled'] else None
|
||||
|
||||
def require_auth(self):
|
||||
if not self.authenticated():
|
||||
self.user = self.authenticated()
|
||||
if not self.user:
|
||||
raise Problem('请先登录', 401)
|
||||
|
||||
def require_admin(self):
|
||||
if self.user['role'] != 'admin':
|
||||
raise Problem('此操作需要管理员权限', 403)
|
||||
|
||||
def body(self):
|
||||
if self.headers.get_content_type() != 'application/json':
|
||||
raise Problem('请求需要 JSON 格式')
|
||||
@@ -509,31 +522,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
ATTEMPTS[ip] = (count + 1, expiry)
|
||||
if count >= 12:
|
||||
raise Problem('尝试次数过多,请 15 分钟后重试', 429)
|
||||
account = setting('account')
|
||||
accounts = objects('users')
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str) or not 10 <= len(password) <= 128:
|
||||
raise Problem('管理密码需要 10 至 128 个字符')
|
||||
if setup:
|
||||
if account:
|
||||
if accounts:
|
||||
raise Problem('管理员已经初始化', 409)
|
||||
code = data.get('code', '')
|
||||
expected = (DATA / 'setup-code.txt').read_text().strip()
|
||||
if not isinstance(code, str) or not hmac.compare_digest(code, expected):
|
||||
raise Problem('初始化码不正确', 403)
|
||||
salt = secrets.token_hex(16)
|
||||
derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()
|
||||
set_setting('account', {'salt': salt, 'hash': derived})
|
||||
account = ACCOUNTS.save({'username': 'admin', 'name': '管理员', 'role': 'admin',
|
||||
'password': password, 'familyAccess': 'edit'}, None)
|
||||
(DATA / 'setup-code.txt').unlink(missing_ok=True)
|
||||
audit('初始化管理员')
|
||||
else:
|
||||
if not account:
|
||||
if not accounts:
|
||||
raise Problem('请先初始化管理员', 409)
|
||||
derived = hashlib.scrypt(password.encode(), salt=account['salt'].encode(), n=16384, r=8, p=1).hex()
|
||||
if not hmac.compare_digest(derived, account['hash']):
|
||||
raise Problem('密码不正确', 403)
|
||||
account = ACCOUNTS.verify(data.get('username', ''), password)
|
||||
if not account:
|
||||
raise Problem('用户名或密码不正确', 403)
|
||||
token = secrets.token_hex(32)
|
||||
DB.execute('DELETE FROM sessions WHERE expires<?', (now,))
|
||||
DB.execute('INSERT INTO sessions VALUES (?,?)', (hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7))
|
||||
DB.execute('INSERT INTO sessions (hash,expires,user_id) VALUES (?,?,?)',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7, account['id']))
|
||||
DB.commit()
|
||||
ATTEMPTS.pop(ip, None)
|
||||
secure = '; Secure' if os.environ.get('VISION_SECURE_COOKIE') == '1' else ''
|
||||
@@ -548,11 +559,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.login(data, path == '/api/setup')
|
||||
return
|
||||
self.require_auth()
|
||||
if path not in ('/api/logout', '/api/family/person', '/api/family/link'):
|
||||
self.require_admin()
|
||||
if path == '/api/logout':
|
||||
with LOCK:
|
||||
DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),))
|
||||
DB.commit()
|
||||
self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
|
||||
elif path == '/api/users':
|
||||
self.answer(ACCOUNTS.save(data, self.user))
|
||||
elif path in ('/api/family/person', '/api/family/link'):
|
||||
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
|
||||
elif path == '/api/sites':
|
||||
self.answer(save_site(data))
|
||||
elif path == '/api/cameras':
|
||||
@@ -684,7 +701,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
path = parsed.path
|
||||
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
|
||||
if path == '/api/session':
|
||||
self.answer({'authenticated': self.authenticated(), 'setupRequired': not bool(setting('account'))})
|
||||
user = self.authenticated()
|
||||
self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user),
|
||||
'setupRequired': not bool(objects('users'))})
|
||||
return
|
||||
if path == '/healthz':
|
||||
self.answer({'status': 'running', 'version': (ROOT / 'VERSION').read_text().strip()})
|
||||
@@ -692,8 +711,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path.startswith(('/api/', '/media/')):
|
||||
self.require_auth()
|
||||
if path == '/api/state':
|
||||
self.answer(status())
|
||||
self.answer(ACCOUNTS.state(self.user, status()))
|
||||
elif path == '/api/users':
|
||||
self.require_admin()
|
||||
self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]})
|
||||
elif path == '/api/family':
|
||||
self.answer(FAMILY.snapshot(self.user))
|
||||
elif path == '/api/audit':
|
||||
self.require_admin()
|
||||
with LOCK:
|
||||
rows = [dict(r) for r in DB.execute('SELECT * FROM audit ORDER BY id DESC LIMIT 50')]
|
||||
self.answer(rows)
|
||||
@@ -701,6 +726,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
c = get_object('cameras', query.get('camera'))
|
||||
if not c:
|
||||
raise Problem('请选择摄像头')
|
||||
ACCOUNTS.camera(self.user, c)
|
||||
start, end = parse_time(query.get('start')), parse_time(query.get('end'))
|
||||
if not 0 < (end - start).total_seconds() <= 172800:
|
||||
raise Problem('每次查询最多两天')
|
||||
@@ -712,11 +738,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
suffix = path[len('/media/live/'):]
|
||||
if not re.fullmatch(r'cam_[a-f0-9]{16}_(main|sub|compat|hd)/[a-zA-Z0-9_.-]+', suffix):
|
||||
raise Problem('视频路径不正确')
|
||||
ACCOUNTS.camera(self.user, get_object('cameras', suffix[4:20]))
|
||||
self.proxy(HLS_PORT, '/' + suffix + ('?' + parsed.query if parsed.query else ''))
|
||||
elif path == '/media/playback':
|
||||
c = get_object('cameras', query.get('camera'))
|
||||
if not c:
|
||||
raise Problem('摄像头不存在', 404)
|
||||
ACCOUNTS.camera(self.user, c)
|
||||
start = parse_time(query.get('start'))
|
||||
try:
|
||||
duration = float(query.get('duration', '300'))
|
||||
@@ -735,6 +763,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Problem('接口不存在', 404)
|
||||
else:
|
||||
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
|
||||
'/family.js': 'family.js', '/kinship.js': 'kinship.js',
|
||||
'/vendor/hls.min.js': 'vendor/hls.min.js'}
|
||||
if path not in files:
|
||||
raise Problem('页面不存在', 404)
|
||||
@@ -760,12 +789,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def initialize():
|
||||
global MEDIA_AUTH
|
||||
FAMILY.initialize()
|
||||
ACCOUNTS.initialize()
|
||||
if not setting('storage'):
|
||||
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
|
||||
if not setting('mediaSecret'):
|
||||
set_setting('mediaSecret', secrets.token_hex(32))
|
||||
MEDIA_AUTH = 'Basic ' + base64.b64encode(('vision:' + setting('mediaSecret')).encode()).decode()
|
||||
if not setting('account') and not (DATA / 'setup-code.txt').exists():
|
||||
if not objects('users') and not (DATA / 'setup-code.txt').exists():
|
||||
(DATA / 'setup-code.txt').write_text(secrets.token_hex(12), encoding='utf8')
|
||||
os.chmod(DATA / 'setup-code.txt', 0o600)
|
||||
write_media_config()
|
||||
|
||||
Reference in New Issue
Block a user