From 20e3edba8fdd19457d58b41f242d96fa4350078b Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 17 Nov 2024 19:11:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/api/http/controller/group.py | 37 ++- pkg/api/http/controller/groups/system.py | 2 +- pkg/api/http/controller/groups/user.py | 43 ++++ pkg/api/http/controller/main.py | 2 +- pkg/api/http/service/user.py | 74 ++++++ pkg/core/app.py | 7 + pkg/core/bootutils/deps.py | 2 + pkg/core/migrations/m013_http_api_config.py | 3 +- pkg/core/stages/build_app.py | 4 + pkg/core/stages/load_config.py | 7 + pkg/persistence/entities/__init__.py | 0 pkg/persistence/entities/base.py | 5 + pkg/persistence/entities/user.py | 11 + pkg/persistence/mgr.py | 8 +- pkg/utils/constants.py | 4 +- requirements.txt | 4 +- templates/schema/system.json | 5 + templates/system.json | 3 +- web/src/App.vue | 260 +++++++++++++------- web/src/components/InitDialog.vue | 88 +++++++ web/src/components/LoginDialog.vue | 72 ++++++ web/src/plugins/index.js | 10 + web/src/store/index.js | 6 + 23 files changed, 549 insertions(+), 108 deletions(-) create mode 100644 pkg/api/http/controller/groups/user.py create mode 100644 pkg/api/http/service/user.py create mode 100644 pkg/persistence/entities/__init__.py create mode 100644 pkg/persistence/entities/base.py create mode 100644 pkg/persistence/entities/user.py create mode 100644 web/src/components/InitDialog.vue create mode 100644 web/src/components/LoginDialog.vue diff --git a/pkg/api/http/controller/group.py b/pkg/api/http/controller/group.py index 053ba1cd..5a6ab97e 100644 --- a/pkg/api/http/controller/group.py +++ b/pkg/api/http/controller/group.py @@ -2,6 +2,7 @@ import abc import typing +import enum import quart from quart.typing import RouteCallable @@ -23,6 +24,12 @@ def decorator(cls: typing.Type[RouterGroup]) -> typing.Type[RouterGroup]: return decorator +class AuthType(enum.Enum): + """认证类型""" + NONE = 'none' + USER_TOKEN = 'user-token' + + class RouterGroup(abc.ABC): name: str @@ -41,13 +48,30 @@ def __init__(self, ap: app.Application, quart_app: quart.Quart) -> None: async def initialize(self) -> None: pass - def route(self, rule: str, **options: typing.Any) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator + def route(self, rule: str, auth_type: AuthType = AuthType.USER_TOKEN, **options: typing.Any) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator """注册一个路由""" def decorator(f: RouteCallable) -> RouteCallable: nonlocal rule rule = self.path + rule async def handler_error(*args, **kwargs): + + if auth_type == AuthType.USER_TOKEN: + # 从Authorization头中获取token + token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') + + if not token: + return self.http_status(401, -1, '未提供有效的用户令牌') + + try: + user_email = await self.ap.user_service.verify_jwt_token(token) + + # 检查f是否接受user_email参数 + if 'user_email' in f.__code__.co_varnames: + kwargs['user_email'] = user_email + except Exception as e: + return self.http_status(401, -1, str(e)) + try: return await f(*args, **kwargs) except Exception as e: # 自动 500 @@ -61,25 +85,22 @@ async def handler_error(*args, **kwargs): return f return decorator - - def _cors(self, response: quart.Response) -> quart.Response: - return response def success(self, data: typing.Any = None) -> quart.Response: """返回一个 200 响应""" - return self._cors(quart.jsonify({ + return quart.jsonify({ 'code': 0, 'msg': 'ok', 'data': data, - })) + }) def fail(self, code: int, msg: str) -> quart.Response: """返回一个异常响应""" - return self._cors(quart.jsonify({ + return quart.jsonify({ 'code': code, 'msg': msg, - })) + }) def http_status(self, status: int, code: int, msg: str) -> quart.Response: """返回一个指定状态码的响应""" diff --git a/pkg/api/http/controller/groups/system.py b/pkg/api/http/controller/groups/system.py index 3b9c57fa..f074531a 100644 --- a/pkg/api/http/controller/groups/system.py +++ b/pkg/api/http/controller/groups/system.py @@ -10,7 +10,7 @@ class SystemRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('/info', methods=['GET']) + @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: return self.success( data={ diff --git a/pkg/api/http/controller/groups/user.py b/pkg/api/http/controller/groups/user.py new file mode 100644 index 00000000..9bc8bf74 --- /dev/null +++ b/pkg/api/http/controller/groups/user.py @@ -0,0 +1,43 @@ +import quart +import sqlalchemy + +from .. import group +from .....persistence.entities import user + + +@group.group_class('user', '/api/v1/user') +class UserRouterGroup(group.RouterGroup): + + async def initialize(self) -> None: + @self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + if quart.request.method == 'GET': + return self.success(data={ + 'initialized': await self.ap.user_service.is_initialized() + }) + + if await self.ap.user_service.is_initialized(): + return self.fail(1, '系统已初始化') + + json_data = await quart.request.json + + user_email = json_data['user'] + password = json_data['password'] + + await self.ap.user_service.create_user(user_email, password) + + return self.success() + + @self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + json_data = await quart.request.json + + token = await self.ap.user_service.authenticate(json_data['user'], json_data['password']) + + return self.success(data={ + 'token': token + }) + + @self.route('/check-token', methods=['GET']) + async def _() -> str: + return self.success() diff --git a/pkg/api/http/controller/main.py b/pkg/api/http/controller/main.py index 8befea43..acbfa104 100644 --- a/pkg/api/http/controller/main.py +++ b/pkg/api/http/controller/main.py @@ -7,7 +7,7 @@ import quart_cors from ....core import app, entities as core_entities -from .groups import logs, system, settings, plugins, stats +from .groups import logs, system, settings, plugins, stats, user from . import group diff --git a/pkg/api/http/service/user.py b/pkg/api/http/service/user.py new file mode 100644 index 00000000..b1d00a73 --- /dev/null +++ b/pkg/api/http/service/user.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import sqlalchemy +import argon2 +import jwt +import datetime + +from ....core import app +from ....persistence.entities import user +from ....utils import constants + + +class UserService: + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def is_initialized(self) -> bool: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(user.User).limit(1) + ) + + result_list = result.all() + return result_list is not None and len(result_list) > 0 + + async def create_user(self, user_email: str, password: str) -> None: + ph = argon2.PasswordHasher() + + hashed_password = ph.hash(password) + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(user.User).values( + user=user_email, + password=hashed_password + ) + ) + + async def authenticate(self, user_email: str, password: str) -> str | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(user.User).where(user.User.user == user_email) + ) + + result_list = result.all() + + if result_list is None or len(result_list) == 0: + raise ValueError('用户不存在') + + user_obj = result_list[0] + + ph = argon2.PasswordHasher() + + if not ph.verify(user_obj.password, password): + raise ValueError('密码错误') + + return await self.generate_jwt_token(user_email) + + async def generate_jwt_token(self, user_email: str) -> str: + jwt_secret = self.ap.instance_secret_meta.data['jwt_secret'] + jwt_expire = self.ap.system_cfg.data['http-api']['jwt-expire'] + + payload = { + 'user': user_email, + 'iss': 'LangBot-'+constants.edition, + 'exp': datetime.datetime.now() + datetime.timedelta(seconds=jwt_expire) + } + + return jwt.encode(payload, jwt_secret, algorithm='HS256') + + async def verify_jwt_token(self, token: str) -> str: + jwt_secret = self.ap.instance_secret_meta.data['jwt_secret'] + + return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user'] diff --git a/pkg/core/app.py b/pkg/core/app.py index 46e70775..5f32f206 100644 --- a/pkg/core/app.py +++ b/pkg/core/app.py @@ -23,6 +23,7 @@ from ..utils import version as version_mgr, proxy as proxy_mgr, announce as announce_mgr from ..persistence import mgr as persistencemgr from ..api.http.controller import main as http_controller +from ..api.http.service import user as user_service from ..utils import logcache, ip from . import taskmgr from . import entities as core_entities @@ -74,6 +75,8 @@ class Application: llm_models_meta: config_mgr.ConfigManager = None + instance_secret_meta: config_mgr.ConfigManager = None + # ========================= ctr_mgr: center_mgr.V2CenterAPI = None @@ -100,6 +103,10 @@ class Application: log_cache: logcache.LogCache = None + # ========= HTTP Services ========= + + user_service: user_service.UserService = None + def __init__(self): pass diff --git a/pkg/core/bootutils/deps.py b/pkg/core/bootutils/deps.py index 56938b08..d83ed7a9 100644 --- a/pkg/core/bootutils/deps.py +++ b/pkg/core/bootutils/deps.py @@ -21,6 +21,8 @@ "aiosqlite": "aiosqlite", "aiofiles": "aiofiles", "aioshutil": "aioshutil", + "argon2": "argon2-cffi", + "jwt": "pyjwt", } diff --git a/pkg/core/migrations/m013_http_api_config.py b/pkg/core/migrations/m013_http_api_config.py index 8d7c453d..c5fe55ba 100644 --- a/pkg/core/migrations/m013_http_api_config.py +++ b/pkg/core/migrations/m013_http_api_config.py @@ -17,7 +17,8 @@ async def run(self): self.ap.system_cfg.data['http-api'] = { "enable": True, "host": "0.0.0.0", - "port": 5300 + "port": 5300, + "jwt-expire": 604800 } self.ap.system_cfg.data['persistence'] = { diff --git a/pkg/core/stages/build_app.py b/pkg/core/stages/build_app.py index bc169b17..c5f5c005 100644 --- a/pkg/core/stages/build_app.py +++ b/pkg/core/stages/build_app.py @@ -17,6 +17,7 @@ from ...platform import manager as im_mgr from ...persistence import mgr as persistencemgr from ...api.http.controller import main as http_controller +from ...api.http.service import user as user_service from ...utils import logcache from .. import taskmgr @@ -112,5 +113,8 @@ async def run(self, ap: app.Application): await http_ctrl.initialize() ap.http_ctrl = http_ctrl + user_service_inst = user_service.UserService(ap) + ap.user_service = user_service_inst + ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/pkg/core/stages/load_config.py b/pkg/core/stages/load_config.py index 29a4c446..732833f5 100644 --- a/pkg/core/stages/load_config.py +++ b/pkg/core/stages/load_config.py @@ -1,5 +1,7 @@ from __future__ import annotations +import secrets + from .. import stage, app from ..bootutils import config from ...config import settings as settings_mgr @@ -75,3 +77,8 @@ async def run(self, ap: app.Application): ap.llm_models_meta = await config.load_json_config("data/metadata/llm-models.json", "templates/metadata/llm-models.json") await ap.llm_models_meta.dump_config() + + ap.instance_secret_meta = await config.load_json_config("data/metadata/instance-secret.json", template_data={ + 'jwt_secret': secrets.token_hex(16) + }) + await ap.instance_secret_meta.dump_config() diff --git a/pkg/persistence/entities/__init__.py b/pkg/persistence/entities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pkg/persistence/entities/base.py b/pkg/persistence/entities/base.py new file mode 100644 index 00000000..b0d8b5db --- /dev/null +++ b/pkg/persistence/entities/base.py @@ -0,0 +1,5 @@ +import sqlalchemy.orm + + +class Base(sqlalchemy.orm.DeclarativeBase): + pass diff --git a/pkg/persistence/entities/user.py b/pkg/persistence/entities/user.py new file mode 100644 index 00000000..55597b4f --- /dev/null +++ b/pkg/persistence/entities/user.py @@ -0,0 +1,11 @@ +import sqlalchemy + +from .base import Base + + +class User(Base): + __tablename__ = 'users' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) diff --git a/pkg/persistence/mgr.py b/pkg/persistence/mgr.py index d0c1fa26..0eef4800 100644 --- a/pkg/persistence/mgr.py +++ b/pkg/persistence/mgr.py @@ -7,6 +7,7 @@ import sqlalchemy from . import database +from .entities import user, base from ..core import app from .databases import sqlite @@ -23,7 +24,7 @@ class PersistenceManager: def __init__(self, ap: app.Application): self.ap = ap - self.meta = sqlalchemy.MetaData() + self.meta = base.Base.metadata async def initialize(self): @@ -46,10 +47,11 @@ async def execute_async( self, *args, **kwargs - ): + ) -> sqlalchemy.engine.cursor.CursorResult: async with self.get_db_engine().connect() as conn: - await conn.execute(*args, **kwargs) + result = await conn.execute(*args, **kwargs) await conn.commit() + return result def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine: return self.db.get_engine() diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 79b470e6..35a5fda7 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,3 +1,5 @@ semantic_version = "v3.3.1.1" -debug_mode = False \ No newline at end of file +debug_mode = False + +edition = 'community' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7eaec08a..632232ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,4 +20,6 @@ sqlalchemy[asyncio] aiosqlite quart-cors aiofiles -aioshutil \ No newline at end of file +aioshutil +argon2-cffi +pyjwt \ No newline at end of file diff --git a/templates/schema/system.json b/templates/schema/system.json index 01286a3c..c2da4232 100644 --- a/templates/schema/system.json +++ b/templates/schema/system.json @@ -92,6 +92,11 @@ }, "port": { "type": "integer" + }, + "jwt-expire": { + "type": "integer", + "title": "JWT 过期时间", + "description": "单位:秒" } } }, diff --git a/templates/system.json b/templates/system.json index bbb97ffb..c090ea0e 100644 --- a/templates/system.json +++ b/templates/system.json @@ -15,7 +15,8 @@ "http-api": { "enable": true, "host": "0.0.0.0", - "port": 5300 + "port": 5300, + "jwt-expire": 604800 }, "persistence": { "sqlite": { diff --git a/web/src/App.vue b/web/src/App.vue index 05446fbf..e1fb3b16 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,104 +1,123 @@ diff --git a/web/src/components/LoginDialog.vue b/web/src/components/LoginDialog.vue new file mode 100644 index 00000000..4cfa50a6 --- /dev/null +++ b/web/src/components/LoginDialog.vue @@ -0,0 +1,72 @@ + + + + + \ No newline at end of file diff --git a/web/src/plugins/index.js b/web/src/plugins/index.js index b359e1f8..3ca203de 100644 --- a/web/src/plugins/index.js +++ b/web/src/plugins/index.js @@ -16,6 +16,16 @@ export function registerPlugins (app) { .use(router) .use(store) + // 读取用户令牌 + const token = localStorage.getItem('user-token') + + if (token) { + store.state.user.jwtToken = token + } + + // 所有axios请求均携带用户令牌 + axios.defaults.headers.common['Authorization'] = `Bearer ${store.state.user.jwtToken}` + app.config.globalProperties.$axios = axios store.commit('initializeFetch') } diff --git a/web/src/store/index.js b/web/src/store/index.js index 2a7e3009..64b126a3 100644 --- a/web/src/store/index.js +++ b/web/src/store/index.js @@ -13,6 +13,12 @@ export default createStore({ version: 'v0.0.0', debug: false, enabledPlatformCount: 0, + user: { + tokenChecked: false, + tokenValid: false, + systemInitialized: true, + jwtToken: '', + } }, mutations: { initializeFetch() {