From 815e7093397e6a8d0052434c482021d95bd85eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Fri, 3 Oct 2025 12:01:01 +0800 Subject: [PATCH] Refactor password handling and model typing Replaced custom password generation and verification logic with a new pkg/password.py module using Argon2 for secure hashing. Updated model field types to use PEP 604 union syntax (e.g., str | None) and improved type annotations. Refactored admin and session routes to use new password utilities and direct model methods for CRUD operations. Removed legacy tool-based password functions and cleaned up .idea project files. --- .idea/.gitignore | 8 -- .idea/Findreve.iml | 14 --- .idea/dataSources.xml | 13 --- .idea/inspectionProfiles/Project_Default.xml | 29 ----- .../inspectionProfiles/profiles_settings.xml | 6 - .idea/material_theme_project_new.xml | 17 --- .idea/misc.xml | 7 -- .idea/modules.xml | 8 -- .idea/runConfigurations/Findreve.xml | 26 ----- .idea/vcs.xml | 6 - JWT.py | 2 +- model/base.py | 6 +- model/database.py | 17 ++- model/items.py | 8 +- model/migration.py | 8 +- model/object.py | 46 +++----- model/response.py | 2 +- model/setting.py | 7 +- pkg/password.py | 72 ++++++++++++ routes/admin.py | 108 ++++++++++++------ routes/object.py | 2 +- routes/session.py | 4 +- tool.py | 68 ----------- 23 files changed, 191 insertions(+), 293 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/Findreve.iml delete mode 100644 .idea/dataSources.xml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/material_theme_project_new.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/runConfigurations/Findreve.xml delete mode 100644 .idea/vcs.xml create mode 100644 pkg/password.py diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 35410ca..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 默认忽略的文件 -/shelf/ -/workspace.xml -# 基于编辑器的 HTTP 客户端请求 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/Findreve.iml b/.idea/Findreve.iml deleted file mode 100644 index 3708bfa..0000000 --- a/.idea/Findreve.iml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index bae54b5..0000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - sqlite.xerial - true - Findreve Data - org.sqlite.JDBC - jdbc:sqlite:$PROJECT_DIR$/data.db - $ProjectFileDir$ - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index ef59b15..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml deleted file mode 100644 index 844ac1b..0000000 --- a/.idea/material_theme_project_new.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 82554e2..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cd62433..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Findreve.xml b/.idea/runConfigurations/Findreve.xml deleted file mode 100644 index f4ac18e..0000000 --- a/.idea/runConfigurations/Findreve.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/JWT.py b/JWT.py index 7fcd52a..4cc7e44 100644 --- a/JWT.py +++ b/JWT.py @@ -22,7 +22,7 @@ async def get_secret_key() -> str: global _SECRET_KEY_CACHE if _SECRET_KEY_CACHE is None: - async with Database.get_session() as session: + async with Database.session_context() as session: setting = await Setting.get( session=session, condition=(Setting.name == 'SECRET_KEY') diff --git a/model/base.py b/model/base.py index a0abcf0..830a432 100644 --- a/model/base.py +++ b/model/base.py @@ -1,6 +1,6 @@ # model/base.py from datetime import datetime, timezone -from typing import Optional, Type, TypeVar, Union, Literal, List +from typing import Type, TypeVar, Union, Literal, List from sqlalchemy import DateTime, BinaryExpression, ClauseElement from sqlalchemy.orm import selectinload @@ -27,7 +27,7 @@ class TableBase(AsyncAttrs, SQLModel): sa_column_kwargs={"default": utcnow, "onupdate": utcnow}, default_factory=utcnow ) - deleted_at: Optional[datetime] = Field( + deleted_at: datetime | None = Field( default=None, description="删除时间", sa_column={"nullable": True} @@ -148,4 +148,4 @@ class TableBase(AsyncAttrs, SQLModel): # 需要“自增 id 主键”的模型才混入它;Setting 不混入 class IdMixin(SQLModel): - id: Optional[int] = Field(default=None, primary_key=True, description="主键ID") \ No newline at end of file + id: int | None = Field(default=None, primary_key=True, description="主键ID") \ No newline at end of file diff --git a/model/database.py b/model/database.py index cbcb623..cc7b8f9 100644 --- a/model/database.py +++ b/model/database.py @@ -42,12 +42,25 @@ class Database: async with _async_session_factory() as session: yield session + @staticmethod + @asynccontextmanager + async def session_context() -> AsyncGenerator[AsyncSession, None]: + """ + 提供异步上下文管理器用于直接获取数据库会话 + + 使用示例: + async with Database.session_context() as session: + # 执行数据库操作 + pass + """ + async with _async_session_factory() as session: + yield session + async def init_db(self, url: str = ASYNC_DATABASE_URL): """创建数据库结构""" async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) # For internal use, create a temporary context manager - get_session_cm = asynccontextmanager(self.get_session) - async with get_session_cm() as session: + async with self.session_context() as session: await migration(session) # 执行迁移脚本 \ No newline at end of file diff --git a/model/items.py b/model/items.py index b39ed3a..47d9d18 100644 --- a/model/items.py +++ b/model/items.py @@ -1,5 +1,4 @@ from pydantic import BaseModel -from typing import Optional class Item(BaseModel): id: int @@ -9,8 +8,7 @@ class Item(BaseModel): icon: str status: str phone: int - lost_description: Optional[str] - find_ip: Optional[str] + lost_description: str | None + find_ip: str | None create_time: str - lost_time: Optional[str] - \ No newline at end of file + lost_time: str | None diff --git a/model/migration.py b/model/migration.py index a903a56..1b9fdca 100644 --- a/model/migration.py +++ b/model/migration.py @@ -1,7 +1,7 @@ from loguru import logger from sqlmodel import select from .setting import Setting -import tool +from pkg.password import Password default_settings: list[Setting] = [ Setting(type='string', name='version', value='1.0.0'), @@ -18,11 +18,11 @@ async def migration(session): return # 生成初始密码与密钥 - admin_password = tool.generate_password() + admin_password = Password.generate() logger.warning(f"密码(请牢记,后续不再显示): {admin_password}") - settings.append(Setting(type='string', name='password', value=tool.hash_password(admin_password))) - settings.append(Setting(type='string', name='SECRET_KEY', value=tool.generate_password(64))) + settings.append(Setting(type='string', name='password', value=Password.hash(admin_password))) + settings.append(Setting(type='string', name='SECRET_KEY', value=Password.generate(64))) # 读取库里已存在的 name,避免主键冲突 names = [s.name for s in settings] diff --git a/model/object.py b/model/object.py index 43c80a9..853aa62 100644 --- a/model/object.py +++ b/model/object.py @@ -1,45 +1,29 @@ # my_project/models/download.py -from typing import Literal, Optional, TYPE_CHECKING -from sqlmodel import Field, Column, SQLModel, String, DateTime +from typing import Literal +from sqlmodel import Field, Column, String, DateTime from .base import TableBase, IdMixin from datetime import datetime -""" -原建表语句: - -CREATE TABLE IF NOT EXISTS fr_objects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT NOT NULL, - name TEXT NOT NULL, - icon TEXT, - status TEXT, - phone TEXT, - context TEXT, - find_ip TEXT, - create_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - lost_at TIMESTAMP -""" - -if TYPE_CHECKING: - pass - class Object(IdMixin, TableBase, table=True): - __tablename__ = 'fr_objects' key: str = Field(index=True, nullable=False, description="物品外部ID") - type: Literal['object', 'box'] = Field( - default='object', + type: Literal['normal', 'car'] = Field( + default='normal', description="物品类型", - sa_column=Column(String, default='object', nullable=False) + sa_column=Column(String, default='normal', nullable=False) ) name: str = Field(nullable=False, description="物品名称") - icon: Optional[str] = Field(default=None, description="物品图标") - status: Optional[str] = Field(default=None, description="物品状态") - phone: Optional[str] = Field(default=None, description="联系电话") - context: Optional[str] = Field(default=None, description="物品描述") - find_ip: Optional[str] = Field(default=None, description="最后一次发现的IP地址") - lost_at: Optional[datetime] = Field( + icon: str | None = Field(default=None, description="物品图标") + status: Literal['ok', 'lost'] = Field( + default='ok', + description="物品状态", + sa_column=Column(String, default='ok', nullable=False) + ) + phone: str | None = Field(default=None, description="联系电话") + context: str | None = Field(default=None, description="物品描述") + find_ip: str | None = Field(default=None, description="最后一次发现的IP地址") + lost_at: datetime | None = Field( default=None, description="物品标记为丢失的时间", sa_column=Column(DateTime, nullable=True) diff --git a/model/response.py b/model/response.py index bf6169a..2b99bfe 100644 --- a/model/response.py +++ b/model/response.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import Literal, Optional +from typing import Literal class DefaultResponse(BaseModel): code: int = 0 diff --git a/model/setting.py b/model/setting.py index bf56756..058b543 100644 --- a/model/setting.py +++ b/model/setting.py @@ -1,5 +1,4 @@ # model/setting.py -from typing import TYPE_CHECKING, Optional from sqlmodel import Field from .base import TableBase @@ -12,12 +11,8 @@ CREATE TABLE IF NOT EXISTS fr_settings ( ) """ -if TYPE_CHECKING: - pass - class Setting(TableBase, table=True): - __tablename__ = 'fr_settings' type: str = Field(index=True, nullable=False, description="设置类型") name: str = Field(primary_key=True, nullable=False, description="设置名称") # name 为唯一主键 - value: Optional[str] = Field(description="设置值") + value: str | None = Field(description="设置值") diff --git a/pkg/password.py b/pkg/password.py new file mode 100644 index 0000000..05d2050 --- /dev/null +++ b/pkg/password.py @@ -0,0 +1,72 @@ +import secrets +from loguru import logger +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +_ph = PasswordHasher() + +class Password(): + + @staticmethod + def generate( + length: int = 8 + ) -> str: + """ + 生成指定长度的随机密码。 + + :param length: 密码长度 + :type length: int + :return: 随机密码 + :rtype: str + """ + return secrets.token_hex(length) + + def hash( + password: str + ) -> str: + """ + 使用 Argon2 生成密码的哈希值。 + + 返回的哈希字符串已经包含了所有需要验证的信息(盐、算法参数等)。 + + :param password: 需要哈希的原始密码 + :return: Argon2 哈希字符串 + """ + return _ph.hash(password) + + def verify( + stored_password: str, + provided_password: str, + debug: bool = False + ) -> bool: + """ + 验证存储的 Argon2 哈希值与用户提供的密码是否匹配。 + + :param stored_password: 数据库中存储的 Argon2 哈希字符串 + :param provided_password: 用户本次提供的密码 + :param debug: 是否输出调试信息 + :return: 如果密码匹配返回 True, 否则返回 False + """ + if debug: + logger.info(f"验证密码: (哈希) {stored_password}") + + try: + # verify 函数会自动解析 stored_password 中的盐和参数 + _ph.verify(stored_password, provided_password) + + # 检查哈希参数是否已过时。如果返回True, + # 意味着你应该使用新的参数重新哈希密码并更新存储。 + # 这是一个很好的实践,可以随着时间推移增强安全性。 + if _ph.check_needs_rehash(stored_password): + logger.warning("密码哈希参数已过时,建议重新哈希并更新。") + + return True + except VerifyMismatchError: + # 这是预期的异常,当密码不匹配时触发。 + if debug: + logger.info("密码不匹配") + return False + except Exception as e: + # 捕获其他可能的错误 + logger.error(f"密码验证过程中发生未知错误: {e}") + return False \ No newline at end of file diff --git a/routes/admin.py b/routes/admin.py index eb9c948..e636e8d 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -10,11 +10,12 @@ from model.response import DefaultResponse from model.items import Item from sqlmodel.ext.asyncio.session import AsyncSession from model import Setting +from model.object import Object # 验证是否为管理员 async def is_admin( token: Annotated[str, Depends(JWT.oauth2_scheme)], - session: Annotated[AsyncSession, Depends(database.Database.get_session)] + session: Annotated[AsyncSession, Depends(database.Database.get_session)], ) -> Literal[True]: ''' 验证是否为管理员。 @@ -29,9 +30,10 @@ async def is_admin( ) try: - payload = jwt.decode(token, JWT.get_secret_key(), algorithms=[JWT.ALGORITHM]) + payload = jwt.decode(token, await JWT.get_secret_key(), algorithms=[JWT.ALGORITHM]) username = payload.get("sub") - if username is None or not await Setting.get(session, Setting.name == 'account') == username: + stored_account = await Setting.get(session, Setting.name == 'account') + if username is None or not stored_account.value == username: raise credentials_exception else: return True @@ -69,6 +71,7 @@ async def verity_admin() -> DefaultResponse: response_description='物品信息列表' ) async def get_items( + session: Annotated[AsyncSession, Depends(database.Database.get_session)], id: int | None = Query(default=None, ge=1, description='物品ID'), key: str | None = Query(default=None, description='物品序列号')): ''' @@ -76,29 +79,33 @@ async def get_items( 不传参数返回所有信息,否则可传入 `id` 或 `key` 进行筛选。 ''' - results = await database.Database().get_object(id=id, key=key) + # 根据条件查询物品 + if id is not None: + results = await Object.get(session, Object.id == id) + results = [results] if results else [] + elif key is not None: + results = await Object.get(session, Object.key == key) + results = [results] if results else [] + else: + results = await Object.get(session, None, fetch_mode="all") - if results is not None: - if not isinstance(results, list): - items = [results] - else: - items = results - item = [] - for i in items: - item.append(Item( - id=i[0], - type=i[1], - key=i[2], - name=i[3], - icon=i[4], - status=i[5], - phone=i[6], - lost_description=i[7], - find_ip=i[8], - create_time=i[9], - lost_time=i[10] + if results: + items = [] + for obj in results: + items.append(Item( + id=obj.id, + type=obj.type, + key=obj.key, + name=obj.name, + icon=obj.icon or "", + status=obj.status or "", + phone=int(obj.phone) if obj.phone and obj.phone.isdigit() else 0, + lost_description=obj.context, + find_ip=obj.find_ip, + create_time=obj.created_at.isoformat(), + lost_time=obj.lost_at.isoformat() if obj.lost_at else None )) - return DefaultResponse(data=item) + return DefaultResponse(data=items) else: return DefaultResponse(data=[]) @@ -110,6 +117,7 @@ async def get_items( response_description='添加物品成功' ) async def add_items( + session: Annotated[AsyncSession, Depends(database.Database.get_session)], key: str, type: Literal['normal', 'car'], name: str, @@ -127,13 +135,16 @@ async def add_items( ''' try: - await database.Database().add_object( - key=key, + # 创建新物品对象 + new_object = Object( + key=key, type=type, - name=name, - icon=icon, + name=name, + icon=icon, phone=phone ) + # 使用 base.py 中的 add 方法 + await Object.add(session, new_object) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) else: @@ -147,6 +158,7 @@ async def add_items( response_description='更新物品成功' ) async def update_items( + session: Annotated[AsyncSession, Depends(database.Database.get_session)], id: int = Query(ge=1), key: str | None = None, name: str | None = None, @@ -174,12 +186,32 @@ async def update_items( ''' try: - await database.Database().update_object( - id=id, - key=key, name=name, icon=icon, status=status, phone=phone, - lost_description=lost_description, find_ip=find_ip, - lost_time=lost_time - ) + # 获取现有物品 + obj = await Object.get_exist_one(session, id) + + # 更新字段 + if key is not None: + obj.key = key + if name is not None: + obj.name = name + if icon is not None: + obj.icon = icon + if status is not None: + obj.status = status + if phone is not None: + obj.phone = str(phone) + if lost_description is not None: + obj.context = lost_description + if find_ip is not None: + obj.find_ip = find_ip + if lost_time is not None: + from datetime import datetime + obj.lost_at = datetime.fromisoformat(lost_time) + + # 保存更新 + await obj.save(session) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) else: @@ -193,6 +225,7 @@ async def update_items( response_description='删除物品成功' ) async def delete_items( + session: Annotated[AsyncSession, Depends(database.Database.get_session)], id: int) -> DefaultResponse: ''' 删除物品信息。 @@ -200,7 +233,12 @@ async def delete_items( - **id**: 物品的ID ''' try: - await database.Database().delete_object(id=id) + # 获取现有物品 + obj = await Object.get_exist_one(session, id) + # 使用 base.py 中的 delete 方法 + await Object.delete(session, obj) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) else: diff --git a/routes/object.py b/routes/object.py index e81ffc3..4bda28d 100644 --- a/routes/object.py +++ b/routes/object.py @@ -68,7 +68,7 @@ async def notify_move_car( Args: item_id (int): 物品ID / Item ID - phone (str, optional): 挪车发起者电话 / Phone number of the person initiating the move. Defaults to None. + phone (str): 挪车发起者电话 / Phone number of the person initiating the move. Defaults to None. """ db = Database() await db.init_db() diff --git a/routes/session.py b/routes/session.py index 288396b..236a24b 100644 --- a/routes/session.py +++ b/routes/session.py @@ -6,7 +6,7 @@ from fastapi.security import OAuth2PasswordRequestForm from fastapi import APIRouter import jwt, JWT from sqlmodel.ext.asyncio.session import AsyncSession -from tool import verify_password +from pkg.password import Password from loguru import logger from model.token import Token @@ -35,7 +35,7 @@ async def authenticate_user(session: AsyncSession, username: str, password: str) logger.error("Account or password not set in settings.") return False - if account.value != username or not verify_password(stored_password.value, password): + if account.value != username or not Password.verify(stored_password.value, password): logger.error("Invalid username or password.") return False diff --git a/tool.py b/tool.py index 4d14d04..86ce08b 100644 --- a/tool.py +++ b/tool.py @@ -1,9 +1,4 @@ -import hashlib -import binascii -import logging from datetime import datetime, timezone -import os -import secrets def format_phone( phone: str, @@ -36,69 +31,6 @@ def format_phone( return separator.join(result) -def generate_password( - length: int = 8 - ) -> str: - """ - 生成指定长度的随机密码。 - - :param length: 密码长度 - :type length: int - :return: 随机密码 - :rtype: str - """ - import secrets - - return secrets.token_hex(length) - -def hash_password( - password: str - ) -> str: - """ - 生成密码的加盐哈希值。 - - :param password: 需要哈希的原始密码 - :type password: str - :return: 包含盐值和哈希值的字符串 - :rtype: str - - 使用SHA-256和PBKDF2算法对密码进行加盐哈希,返回盐值和哈希值的组合。 - """ - salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') - pwdhash = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) - pwdhash = binascii.hexlify(pwdhash) - return (salt + pwdhash).decode('ascii') - -def verify_password( - stored_password: str, - provided_password: str, - debug: bool = False - ) -> bool: - """ - 验证存储的密码哈希值与用户提供的密码是否匹配。 - - :param stored_password: 存储的密码哈希值(包含盐值) - :type stored_password: str - :param provided_password: 用户提供的密码 - :type provided_password: str - :param debug: 是否输出调试信息,将会输出原密码和哈希值 - :type debug: bool - :return: 如果密码匹配返回True,否则返回False - :rtype: bool - - 从存储的密码哈希中提取盐值,使用相同的哈希算法验证用户提供的密码。 - """ - salt = stored_password[:64] - stored_password = stored_password[64:] - pwdhash = hashlib.pbkdf2_hmac('sha256', - provided_password.encode('utf-8'), - salt.encode('ascii'), - 100000) - pwdhash = binascii.hexlify(pwdhash).decode('ascii') - if debug: - logging.info(f"原密码: {provided_password}, 哈希值: {pwdhash}, 存储哈希值: {stored_password}") - return secrets.compare_digest(pwdhash, stored_password) - def format_time_diff( target_time: datetime | str ) -> str: