feat: add multi-provider auth via AuthIdentity and extend site config

- Extract AuthIdentity model for multi-provider authentication (email_password, OAuth, Passkey, Magic Link)
- Remove password field from User model, credentials now stored in AuthIdentity
- Refactor unified login/register to use AuthIdentity-based provider checking
- Add site config fields: footer_code, tos_url, privacy_url, auth_methods
- Add auth settings defaults in migration (email_password enabled by default)
- Update admin user creation to create AuthIdentity records
- Update all tests to use AuthIdentity model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 22:49:12 +08:00
parent d831c9c0d6
commit 729773cae3
20 changed files with 1447 additions and 412 deletions

View File

@@ -12,6 +12,7 @@ from sqlmodels import (
Group, Object, ObjectType, Setting, SettingsType,
BatchDeleteRequest,
)
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
from sqlmodels.user import (
UserAdminCreateRequest, UserAdminUpdateRequest, UserCalibrateResponse, UserStatus,
)
@@ -83,13 +84,26 @@ async def router_admin_create_user(
"""
创建一个新的用户,设置邮箱、密码、用户组等信息。
管理员创建用户时,若提供了 email + password
会同时创建 AuthIdentity(provider=email_password)。
:param session: 数据库会话
:param request: 创建用户请求 DTO
:return: 创建结果
"""
existing_user = await User.get(session, User.email == request.email)
if existing_user:
raise HTTPException(status_code=409, detail="该邮箱已被注册")
# 如果提供了邮箱检查唯一性User 表和 AuthIdentity 表)
if request.email:
existing_user = await User.get(session, User.email == request.email)
if existing_user:
raise HTTPException(status_code=409, detail="该邮箱已被注册")
existing_identity = await AuthIdentity.get(
session,
(AuthIdentity.provider == AuthProviderType.EMAIL_PASSWORD)
& (AuthIdentity.identifier == request.email),
)
if existing_identity:
raise HTTPException(status_code=409, detail="该邮箱已被绑定")
# 验证用户组存在
group = await Group.get(session, Group.id == request.group_id)
@@ -98,12 +112,24 @@ async def router_admin_create_user(
user = User(
email=request.email,
password=Password.hash(request.password),
nickname=request.nickname,
group_id=request.group_id,
status=request.status,
)
user = await user.save(session)
# 如果提供了邮箱和密码,创建邮箱密码认证身份
if request.email and request.password:
identity = AuthIdentity(
provider=AuthProviderType.EMAIL_PASSWORD,
identifier=request.email,
credential=Password.hash(request.password),
is_primary=True,
is_verified=True,
user_id=user.id,
)
await identity.save(session)
return user.to_public()
@@ -148,17 +174,7 @@ async def router_admin_update_user(
if not group:
raise HTTPException(status_code=400, detail="目标用户组不存在")
# 如果更新密码,需要加密
update_data = request.model_dump(exclude_unset=True)
if 'password' in update_data and update_data['password']:
update_data['password'] = Password.hash(update_data['password'])
elif 'password' in update_data:
del update_data['password'] # 空密码不更新
# 验证两步验证密钥格式(如果提供了值且不为 None长度必须为 32
if 'two_factor' in update_data and update_data['two_factor'] is not None:
if len(update_data['two_factor']) != 32:
raise HTTPException(status_code=400, detail="两步验证密钥必须为32位字符串")
# 记录旧 status 以便检测变更
old_status = user.status
@@ -175,7 +191,7 @@ async def router_admin_update_user(
elif old_status != UserStatus.ACTIVE and new_status == UserStatus.ACTIVE:
await UserBanStore.unban(str(user_id))
l.info(f"管理员更新了用户: {request.email}")
l.info(f"管理员更新了用户: {user.email}")
@admin_user_router.delete(