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

@@ -1,4 +1,5 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
@@ -9,6 +10,7 @@ from middleware.dependencies import SessionDep
from sqlmodels import (
BUILTIN_DEFAULT_COLORS, ThemePreset, UserThemeUpdateRequest,
SettingOption, UserSettingUpdateRequest,
AuthIdentity, AuthIdentityResponse, AuthProviderType, BindIdentityRequest,
)
from sqlmodels.color import ThemeColorsBase
from utils import JWT, Password, http_exceptions
@@ -117,16 +119,29 @@ async def router_user_settings(
else:
theme_colors = BUILTIN_DEFAULT_COLORS
# 检查是否启用了两步验证(从 email_password AuthIdentity 的 extra_data 中读取)
has_two_factor = False
email_identity: AuthIdentity | None = await AuthIdentity.get(
session,
(AuthIdentity.user_id == user.id)
& (AuthIdentity.provider == AuthProviderType.EMAIL_PASSWORD),
)
if email_identity and email_identity.extra_data:
import orjson
extra: dict = orjson.loads(email_identity.extra_data)
has_two_factor = bool(extra.get("two_factor"))
return sqlmodels.UserSettingResponse(
id=user.id,
email=user.email,
phone=user.phone,
nickname=user.nickname,
created_at=user.created_at,
group_name=user.group.name,
language=user.language,
timezone=user.timezone,
group_expires=user.group_expires,
two_factor=user.two_factor is not None,
two_factor=has_two_factor,
theme_preset_id=user.theme_preset_id,
theme_colors=theme_colors,
)
@@ -255,7 +270,7 @@ async def router_user_settings_2fa(
返回 setup_token用于后续验证请求和 uri用于生成二维码
"""
return await Password.generate_totp(name=user.email)
return await Password.generate_totp(name=user.email or str(user.id))
@user_settings_router.post(
@@ -273,7 +288,7 @@ async def router_user_settings_2fa_enable(
"""
启用两步验证
请求体包含 setup_tokenGET /2fa 返回的令牌)和 code6 位验证码)
将 2FA secret 存储到 email_password AuthIdentity 的 extra_data 中
"""
serializer = URLSafeTimedSerializer(JWT.SECRET_KEY)
@@ -287,6 +302,150 @@ async def router_user_settings_2fa_enable(
if Password.verify_totp(secret, request.code) != PasswordStatus.VALID:
raise HTTPException(status_code=400, detail="Invalid OTP code")
# 3. 将 secret 存储到用户的数据库记录中,启用 2FA
user.two_factor = secret
user = await user.save(session)
# 将 secret 存储到 AuthIdentity.extra_data 中
email_identity: AuthIdentity | None = await AuthIdentity.get(
session,
(AuthIdentity.user_id == user.id)
& (AuthIdentity.provider == AuthProviderType.EMAIL_PASSWORD),
)
if not email_identity:
raise HTTPException(status_code=400, detail="未找到邮箱密码认证身份")
import orjson
extra: dict = orjson.loads(email_identity.extra_data) if email_identity.extra_data else {}
extra["two_factor"] = secret
email_identity.extra_data = orjson.dumps(extra).decode('utf-8')
await email_identity.save(session)
# ==================== 认证身份管理 ====================
@user_settings_router.get(
path='/identities',
summary='列出已绑定的认证身份',
)
async def router_user_settings_identities(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> list[AuthIdentityResponse]:
"""
列出当前用户已绑定的所有认证身份
返回:
- 认证身份列表,包含 provider、identifier、display_name 等
"""
identities: list[AuthIdentity] = await AuthIdentity.get(
session,
AuthIdentity.user_id == user.id,
fetch_mode="all",
)
return [identity.to_response() for identity in identities]
@user_settings_router.post(
path='/identity',
summary='绑定新的认证身份',
status_code=status.HTTP_201_CREATED,
)
async def router_user_settings_bind_identity(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
request: BindIdentityRequest,
) -> AuthIdentityResponse:
"""
绑定新的登录方式
请求体:
- provider: 提供者类型
- identifier: 标识符(邮箱 / 手机号 / OAuth code
- credential: 凭证(密码、验证码等)
- redirect_uri: OAuth 回调地址(可选)
错误处理:
- 400: provider 未启用
- 409: 该身份已被其他用户绑定
"""
# 检查是否已被绑定
existing = await AuthIdentity.get(
session,
(AuthIdentity.provider == request.provider)
& (AuthIdentity.identifier == request.identifier),
)
if existing:
raise HTTPException(status_code=409, detail="该身份已被绑定")
# 处理密码类型的凭证
credential: str | None = None
if request.provider == AuthProviderType.EMAIL_PASSWORD and request.credential:
credential = Password.hash(request.credential)
identity = AuthIdentity(
provider=request.provider,
identifier=request.identifier,
credential=credential,
is_primary=False,
is_verified=False,
user_id=user.id,
)
identity = await identity.save(session)
return identity.to_response()
@user_settings_router.delete(
path='/identity/{identity_id}',
summary='解绑认证身份',
status_code=status.HTTP_204_NO_CONTENT,
)
async def router_user_settings_unbind_identity(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
identity_id: UUID,
) -> None:
"""
解绑一个认证身份
约束:
- 不能解绑最后一个身份
- 站长配置强制绑定邮箱/手机号时,不能解绑对应身份
错误处理:
- 404: 身份不存在或不属于当前用户
- 400: 不能解绑最后一个身份 / 不能解绑强制绑定的身份
"""
# 查找目标身份
identity: AuthIdentity | None = await AuthIdentity.get(
session,
(AuthIdentity.id == identity_id) & (AuthIdentity.user_id == user.id),
)
if not identity:
http_exceptions.raise_not_found("认证身份不存在")
# 检查是否为最后一个身份
all_identities: list[AuthIdentity] = await AuthIdentity.get(
session,
AuthIdentity.user_id == user.id,
fetch_mode="all",
)
if len(all_identities) <= 1:
http_exceptions.raise_bad_request("不能解绑最后一个认证身份")
# 检查强制绑定约束
if identity.provider == AuthProviderType.EMAIL_PASSWORD:
email_required_setting = await sqlmodels.Setting.get(
session,
(sqlmodels.Setting.type == sqlmodels.SettingsType.AUTH)
& (sqlmodels.Setting.name == "auth_email_binding_required"),
)
if email_required_setting and email_required_setting.value == "1":
http_exceptions.raise_bad_request("站长要求必须绑定邮箱,不能解绑")
if identity.provider == AuthProviderType.PHONE_SMS:
phone_required_setting = await sqlmodels.Setting.get(
session,
(sqlmodels.Setting.type == sqlmodels.SettingsType.AUTH)
& (sqlmodels.Setting.name == "auth_phone_binding_required"),
)
if phone_required_setting and phone_required_setting.value == "1":
http_exceptions.raise_bad_request("站长要求必须绑定手机号,不能解绑")
await AuthIdentity.delete(session, identity)