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.
This commit is contained in:
2025-10-03 12:01:01 +08:00
parent 1491fc0fbd
commit 815e709339
23 changed files with 191 additions and 293 deletions

8
.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

14
.idea/Findreve.iml generated
View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (Findreve)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

13
.idea/dataSources.xml generated
View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="data.db" uuid="c73289bb-3157-4ec3-b3f4-744da38bc64a">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<remarks>Findreve Data</remarks>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -1,29 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyInterpreterInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="PyMissingTypeHintsInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="E402" />
<option value="E713" />
<option value="E271" />
<option value="E302" />
<option value="E265" />
<option value="W292" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false">
<option name="ignoredErrors">
<list>
<option value="N802" />
<option value="N806" />
</list>
</option>
</inspection_tool>
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>

View File

@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="4dc9a07a:18f958e7499:-7ffe" />
</MTProjectMetadataState>
</option>
<option name="titleBarState">
<MTProjectTitleBarConfigState>
<option name="overrideColor" value="false" />
</MTProjectTitleBarConfigState>
</option>
</component>
</project>

7
.idea/misc.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12 (Findreve)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (Findreve)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Findreve.iml" filepath="$PROJECT_DIR$/.idea/Findreve.iml" />
</modules>
</component>
</project>

View File

@@ -1,26 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="启动Findreve" type="PythonConfigurationType" factoryName="Python">
<module name="Findreve" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.12 (Findreve)" />
<option name="WORKING_DIRECTORY" value="C:\Users\admin\Documents\Code\Findreve" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/main.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

2
JWT.py
View File

@@ -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')

View File

@@ -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")
id: int | None = Field(default=None, primary_key=True, description="主键ID")

View File

@@ -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) # 执行迁移脚本

View File

@@ -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]
lost_time: str | None

View File

@@ -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]

View File

@@ -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)

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel
from typing import Literal, Optional
from typing import Literal
class DefaultResponse(BaseModel):
code: int = 0

View File

@@ -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="设置值")

72
pkg/password.py Normal file
View File

@@ -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

View File

@@ -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:

View File

@@ -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()

View File

@@ -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

68
tool.py
View File

@@ -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: