"""Local accounts. Passwords never appear in public account representations.""" import hashlib import hmac import json import re import secrets import time class Accounts: def __init__(self, app): self.a = app self.password_attempts = {} def initialize(self): a = self.a with a.LOCK: a.DB.execute('CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, body TEXT NOT NULL)') if 'user_id' not in [r['name'] for r in a.DB.execute('PRAGMA table_info(sessions)')]: a.DB.execute('ALTER TABLE sessions ADD COLUMN user_id TEXT') legacy = a.setting('account') if legacy and not a.objects('users'): user = dict(id=secrets.token_hex(8), username='admin', name='管理员', role='admin', disabled=False, siteIds=[], familyAccess='edit', personId='', relatedToId='', relationship='', **legacy) a.DB.execute('INSERT INTO users VALUES (?,?)', (user['id'], json.dumps(user))) a.DB.execute('UPDATE sessions SET user_id=? WHERE user_id IS NULL', (user['id'],)) a.DB.execute("DELETE FROM settings WHERE key='account'") a.DB.commit() @staticmethod def public(user): return {k: v for k, v in user.items() if k not in ('salt', 'hash') and not k.startswith('_')} if user else None def password(self, password): if not isinstance(password, str) or not 8 <= len(password) <= 128: raise self.a.Problem('密码需要 8 至 128 个字符') salt = secrets.token_hex(16) return {'salt': salt, 'hash': hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()} def verify(self, username, password): if not isinstance(username, str) or not isinstance(password, str) or len(password) > 128: return None user = next((u for u in self.a.objects('users') if u['username'] == username.strip().lower()), None) # Also perform derivation for nonexistent accounts. salt = user['salt'] if user else '0' * 32 derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex() return user if user and not user['disabled'] and hmac.compare_digest(derived, user['hash']) else None def save(self, data, actor): a = self.a with a.LOCK: if actor and not a.HOUSEHOLDS.manager(actor): raise a.Problem('需要家庭管理员权限', 403) family = a.HOUSEHOLDS.prepare(actor, 'users', data) old = a.get_object('users', data.get('id')) if data.get('id') and not old: raise a.Problem('账号不存在', 404) uid = old['id'] if old else secrets.token_hex(8) username = a.clean_text(data.get('username', ''), 32, True).lower() if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,31}', username): raise a.Problem('用户名使用 3 至 32 位英文字母、数字、点、下划线或短横线') if any(u['username'] == username and u['id'] != uid for u in a.objects('users')): raise a.Problem('用户名已存在', 409) role = data.get('role', 'member') access = data.get('familyAccess', 'none') heritage_access = data.get('heritageAccess', old.get('heritageAccess', 'none') if old else 'none') if heritage_access not in ('none', 'read', 'edit'): raise a.Problem('文化与财产档案权限不正确') if role not in ('admin', 'family_admin', 'member') or access not in ('none', 'read', 'edit'): raise a.Problem('权限选项不正确') if actor and actor['role'] != 'admin' and (role == 'admin' or (old and old['role'] == 'admin')): raise a.Problem('家庭管理员不能创建或修改超级管理员', 403) home = a.get_object('households', family) if actor and actor['role'] != 'admin' and home.get('ownerId') and actor['id'] != home['ownerId']: if (role == 'family_admin' and not old) or (old and old['id'] != actor['id'] and (role == 'family_admin' or old['role'] == 'family_admin')): raise a.Problem('请由一家之主或超级管理员管理家庭管理员账号', 403) sites = data.get('siteIds', []) if not isinstance(sites, list) or any(not isinstance(s, str) or not a.get_object('sites', s) for s in sites): raise a.Problem('请选择有效空间') for site in sites: a.HOUSEHOLDS.owns(actor, a.get_object('sites', site)) ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False) if not isinstance(ptz_control, bool): raise a.Problem('云台权限格式不正确') descendants = data.get('includeSubspaces', old.get('includeSubspaces', False) if old else False) if not isinstance(descendants, bool): raise a.Problem('下级区域授权格式不正确') disabled = data.get('disabled', False) if not isinstance(disabled, bool): raise a.Problem('账号状态不正确') if old and uid in (home.get('ownerId'), home.get('successorId')) and (disabled or role != 'family_admin'): raise a.Problem('请先交接家主或更换备用负责人,再停用或调整此账号') if old and old['role'] == 'admin' and (role != 'admin' or disabled): if not any(u['id'] != uid and u['role'] == 'admin' and not u['disabled'] for u in a.objects('users')): raise a.Problem('至少保留一个启用的管理员') if old and old['role'] == 'family_admin' and (role != 'family_admin' or disabled): if not any(u['id'] != uid and u['role'] == 'family_admin' and not u['disabled'] for u in a.HOUSEHOLDS.objects(actor, 'users')): raise a.Problem('至少保留一个启用的家庭管理员') if actor and actor['id'] == uid and (disabled or role != actor['role']): raise a.Problem('不能停用或降低当前登录账号的管理权限') related = a.clean_text(data.get('relatedToId', ''), 32) person = a.clean_text(data.get('personId', ''), 32) if related and related != uid and not a.get_object('users', related): raise a.Problem('关系参照账号不存在') if related and related != uid: a.HOUSEHOLDS.owns(actor, a.get_object('users', related)) if person: if not a.get_object('people', person): raise a.Problem('家谱人物不存在') a.HOUSEHOLDS.owns(actor, a.get_object('people', person)) if any(u.get('personId') == person and u['id'] != uid for u in a.objects('users')): raise a.Problem('这个人物已经关联其他账号') password = data.get('password', '') if not isinstance(password, str): raise a.Problem('密码格式不正确') credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')} user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True), role=role, disabled=disabled, familyId=family, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control, personId=person, relatedToId=related, includeSubspaces=descendants, heritageAccess=heritage_access, relationship=a.clean_text(data.get('relationship', ''), 40), **credentials) # Revoke sessions when credentials or authorization change, not for label edits. if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'includeSubspaces', 'familyAccess', 'heritageAccess', 'disabled', 'username', 'ptzControl', 'personId')): a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,)) a.save_object('users', user) a.audit('更新账号' if old else '建立账号', username) return self.public(user) def change_password(self, data, actor): a = self.a with a.LOCK: current = a.get_object('users', actor['id']) if not current or current['disabled']: raise a.Problem('请重新登录', 401) now = time.time() self.password_attempts = {k: v for k, v in self.password_attempts.items() if v[1] > now} count, expiry = self.password_attempts.get(current['id'], (0, now + 900)) if count >= 8: raise a.Problem('当前密码尝试次数过多,请 15 分钟后重试', 429) if not self.verify(current['username'], data.get('currentPassword')): self.password_attempts[current['id']] = (count + 1, expiry) raise a.Problem('当前密码不正确', 403) if data.get('newPassword') == data.get('currentPassword'): raise a.Problem('新密码不能与当前密码相同') credentials = self.password(data.get('newPassword')) with a.DB: a.DB.execute('UPDATE users SET body=? WHERE id=?', (json.dumps(dict(current, **credentials)), current['id'])) a.DB.execute('DELETE FROM sessions WHERE user_id=?', (current['id'],)) self.password_attempts.pop(current['id'], None) a.audit('本人修改密码', current['username']) return {'ok': True} def camera(self, user, camera): if not camera: raise self.a.Problem('摄像头不存在', 404) if user['role'] != 'admin': self.a.HOUSEHOLDS.owns(user, camera) site = self.a.get_object('sites', camera.get('siteId')) if site: self.a.HOUSEHOLDS.owns(user, site) if not self.a.HOUSEHOLDS.manager(user) and camera.get('siteId') not in self.a.HOUSEHOLDS.site_ids(user): raise self.a.Problem('没有这个空间的访问权限', 403) return camera def control(self, user, camera): self.camera(user, camera) if not self.a.HOUSEHOLDS.manager(user) and not user.get('ptzControl', False): raise self.a.Problem('没有云台控制权限,请联系管理员授权', 403) if not camera.get('enabled'): raise self.a.Problem('摄像头已停用', 409) return camera def state(self, user, state): state['user'] = self.public(user) family = self.a.HOUSEHOLDS.scope(user) state['familyId'] = family state['households'] = self.a.HOUSEHOLDS.list(user) state['householdLayout'] = self.a.HOUSEHOLDS.layout(user) for key in ('sites', 'assets', 'cameras', 'recorders', 'devices'): state[key] = [x for x in state.get(key, []) if self.a.HOUSEHOLDS.family_of(x) == family] for key in ('assets', 'recorders', 'devices'): state[key] = [self.a.INVENTORY.display(x) for x in state[key]] if user['role'] != 'admin': state['scan'] = {} state['storage'] = {} if self.a.HOUSEHOLDS.manager(user): state['responsibilityOptions'] = self.a.INVENTORY.candidates(user) return state permitted = self.a.HOUSEHOLDS.site_ids(user) state['sites'] = [s for s in state['sites'] if s['id'] in permitted] state['assets'] = [s for s in state['assets'] if s['siteId'] in permitted] state['devices'] = [s for s in state['devices'] if s['siteId'] in permitted] fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'recordingState', 'archiveSource') state['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted] state['recorders'] = [] state['scan'] = {} state['storage'] = {} return state