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,9 +1,16 @@
from .auth_identity import (
AuthIdentity,
AuthIdentityResponse,
AuthProviderType,
BindIdentityRequest,
)
from .user import (
BatchDeleteRequest,
JWTPayload,
LoginRequest,
MagicLinkRequest,
UnifiedLoginRequest,
UnifiedRegisterRequest,
RefreshTokenRequest,
RegisterRequest,
AccessTokenBase,
RefreshTokenBase,
TokenResponse,
@@ -89,7 +96,7 @@ from .policy import Policy, PolicyBase, PolicyOptions, PolicyOptionsBase, Policy
from .redeem import Redeem, RedeemType
from .report import Report, ReportReason
from .setting import (
Setting, SettingsType, SiteConfigResponse,
Setting, SettingsType, SiteConfigResponse, AuthMethodConfig,
# 管理员DTO
SettingItem, SettingsListResponse, SettingsUpdateRequest, SettingsUpdateResponse,
)
@@ -120,4 +127,4 @@ from .model_base import (
)
# mixin 中的通用分页模型
from .mixin import ListResponse
from .mixin import ListResponse

139
sqlmodels/auth_identity.py Normal file
View File

@@ -0,0 +1,139 @@
"""
认证身份模块
一个用户可拥有多种登录方式邮箱密码、OAuth、Passkey、Magic Link 等)。
AuthIdentity 表存储每种认证方式的凭证信息。
"""
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from sqlmodel import Field, Relationship, UniqueConstraint
from .base import SQLModelBase
from .mixin import UUIDTableBaseMixin
if TYPE_CHECKING:
from .user import User
class AuthProviderType(StrEnum):
"""认证提供者类型"""
EMAIL_PASSWORD = "email_password"
"""邮箱+密码"""
PHONE_SMS = "phone_sms"
"""手机号+短信验证码(预留)"""
GITHUB = "github"
"""GitHub OAuth"""
QQ = "qq"
"""QQ OAuth"""
PASSKEY = "passkey"
"""Passkey/WebAuthn"""
MAGIC_LINK = "magic_link"
"""邮箱 Magic Link"""
# ==================== DTO 模型 ====================
class AuthIdentityResponse(SQLModelBase):
"""认证身份响应 DTO列表展示用"""
id: UUID
"""身份UUID"""
provider: AuthProviderType
"""提供者类型"""
identifier: str
"""标识符(邮箱/手机号/OAuth openid"""
display_name: str | None = None
"""显示名称OAuth 昵称等)"""
avatar_url: str | None = None
"""头像 URL"""
is_primary: bool = False
"""是否主要身份"""
is_verified: bool = False
"""是否已验证"""
class BindIdentityRequest(SQLModelBase):
"""绑定认证身份请求 DTO"""
provider: AuthProviderType
"""提供者类型"""
identifier: str
"""标识符(邮箱/手机号/OAuth code"""
credential: str | None = None
"""凭证(密码、验证码等)"""
redirect_uri: str | None = None
"""OAuth 回调地址"""
# ==================== 数据库模型 ====================
class AuthIdentity(SQLModelBase, UUIDTableBaseMixin):
"""用户认证身份 — 一个用户可以有多种登录方式"""
__table_args__ = (
UniqueConstraint("provider", "identifier", name="uq_auth_identity_provider_identifier"),
)
provider: AuthProviderType = Field(index=True)
"""提供者类型"""
identifier: str = Field(max_length=255, index=True)
"""标识符(邮箱/手机号/OAuth openid"""
credential: str | None = Field(default=None, max_length=1024)
"""凭证Argon2 哈希密码 / null"""
display_name: str | None = Field(default=None, max_length=100)
"""OAuth 昵称"""
avatar_url: str | None = Field(default=None, max_length=512)
"""OAuth 头像 URL"""
extra_data: str | None = None
"""JSON 附加数据2FA secret、OAuth refresh_token 等)"""
is_primary: bool = False
"""是否主要身份"""
is_verified: bool = False
"""是否已验证"""
# 外键
user_id: UUID = Field(
foreign_key="user.id",
index=True,
ondelete="CASCADE",
)
"""所属用户UUID"""
# 关系
user: "User" = Relationship(back_populates="auth_identities")
def to_response(self) -> AuthIdentityResponse:
"""转换为响应 DTO"""
return AuthIdentityResponse(
id=self.id,
provider=self.provider,
identifier=self.identifier,
display_name=self.display_name,
avatar_url=self.avatar_url,
is_primary=self.is_primary,
is_verified=self.is_verified,
)

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,7 @@ from enum import StrEnum
from sqlmodel import UniqueConstraint
from .auth_identity import AuthProviderType
from .base import SQLModelBase
from .mixin import TableBaseMixin
from .user import UserResponse
@@ -12,6 +13,19 @@ class CaptchaType(StrEnum):
GCAPTCHA = "gcaptcha"
CLOUD_FLARE_TURNSTILE = "cloudflare turnstile"
# ==================== Auth 配置 DTO ====================
class AuthMethodConfig(SQLModelBase):
"""认证方式配置 DTO"""
provider: AuthProviderType
"""提供者类型"""
is_enabled: bool
"""是否启用"""
# ==================== DTO 模型 ====================
class SiteConfigResponse(SQLModelBase):
@@ -50,6 +64,27 @@ class SiteConfigResponse(SQLModelBase):
captcha_key: str | None = None
"""验证码 public keyDEFAULT 类型时为 None"""
auth_methods: list[AuthMethodConfig] = []
"""可用的登录方式列表"""
password_required: bool = True
"""注册时是否必须设置密码"""
phone_binding_required: bool = False
"""是否强制绑定手机号"""
email_binding_required: bool = True
"""是否强制绑定邮箱"""
footer_code: str | None = None
"""自定义页脚代码"""
tos_url: str | None = None
"""服务条款 URL"""
privacy_url: str | None = None
"""隐私政策 URL"""
# ==================== 管理员设置 DTO ====================
@@ -133,4 +168,4 @@ class Setting(SettingItem, TableBaseMixin):
__table_args__ = (UniqueConstraint("type", "name", name="uq_setting_type_name"),)
type: SettingsType
"""设置类型/分组(覆盖基类的 str 类型为枚举类型)"""
"""设置类型/分组(覆盖基类的 str 类型为枚举类型)"""

View File

@@ -9,6 +9,7 @@ from sqlmodel import Field, Relationship
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.main import RelationshipInfo
from .auth_identity import AuthProviderType
from .base import SQLModelBase
from .color import ChromaticColor, NeutralColor, ThemeColorsBase
from .model_base import ResponseBase
@@ -17,6 +18,7 @@ from .mixin import UUIDTableBaseMixin, TableViewRequest, ListResponse
T = TypeVar("T", bound="User")
if TYPE_CHECKING:
from .auth_identity import AuthIdentity
from .group import Group
from .download import Download
from .object import Object
@@ -30,7 +32,7 @@ if TYPE_CHECKING:
class AvatarType(StrEnum):
"""头像类型枚举"""
DEFAULT = "default"
GRAVATAR = "gravatar"
FILE = "file"
@@ -69,8 +71,8 @@ class UserFilterParams(SQLModelBase):
class UserBase(SQLModelBase):
"""用户基础字段,供数据库模型和 DTO 共享"""
email: str
"""用户邮箱"""
email: str | None = None
"""用户邮箱(社交登录用户可能没有邮箱)"""
status: UserStatus = UserStatus.ACTIVE
"""用户状态"""
@@ -81,30 +83,42 @@ class UserBase(SQLModelBase):
# ==================== DTO 模型 ====================
class LoginRequest(SQLModelBase):
"""登录请求 DTO"""
class UnifiedLoginRequest(SQLModelBase):
"""统一登录请求 DTO"""
email: str
"""用户邮箱"""
provider: AuthProviderType
"""登录方式"""
password: str
"""用户密码"""
identifier: str
"""标识符(邮箱 / OAuth code / Magic Link token"""
captcha: str | None = None
"""验证码"""
credential: str | None = None
"""凭证密码provider=email_password 时必填)"""
two_fa_code: str | None = Field(default=None, min_length=6, max_length=6)
"""两步验证代码"""
redirect_uri: str | None = None
"""OAuth 回调地址"""
class RegisterRequest(SQLModelBase):
"""注册请求 DTO"""
captcha: str | None = None
"""验证码"""
email: str
"""用户邮箱,唯一"""
password: str
"""用户密码"""
class UnifiedRegisterRequest(SQLModelBase):
"""统一注册请求 DTO"""
provider: AuthProviderType
"""注册方式email_password / phone_sms"""
identifier: str
"""标识符(邮箱 / 手机号)"""
credential: str | None = None
"""凭证(密码 / 短信验证码)"""
nickname: str | None = Field(default=None, max_length=50)
"""昵称"""
captcha: str | None = None
"""验证码"""
@@ -190,7 +204,7 @@ class UserResponse(ResponseBase):
id: UUID
"""用户UUID"""
email: str
email: str | None = None
"""用户邮箱"""
nickname: str | None = None
@@ -216,10 +230,10 @@ class UserStorageResponse(SQLModelBase):
used: int
"""已用存储空间(字节)"""
free: int
"""剩余存储空间(字节)"""
total: int
"""总存储空间(字节)"""
@@ -248,9 +262,6 @@ class UserPublic(UserBase):
group_name: str | None = None
"""用户组名称"""
two_factor: str | None = None
"""两步验证密钥32位字符串null 表示未启用)"""
created_at: datetime | None = None
"""创建时间"""
@@ -264,21 +275,24 @@ class UserSettingResponse(SQLModelBase):
id: UUID
"""用户UUID"""
email: str
email: str | None = None
"""用户邮箱"""
phone: str | None = None
"""手机号"""
nickname: str | None = None
"""昵称"""
created_at: datetime
"""用户注册时间"""
group_name: str
"""用户所属用户组名称"""
language: str
"""语言偏好"""
timezone: int
"""时区"""
@@ -341,16 +355,26 @@ class UserTwoFactorResponse(SQLModelBase):
"""两步验证密钥"""
class MagicLinkRequest(SQLModelBase):
"""Magic Link 请求 DTO"""
email: str
"""接收 Magic Link 的邮箱"""
captcha: str | None = None
"""验证码"""
# ==================== 管理员用户管理 DTO ====================
class UserAdminCreateRequest(SQLModelBase):
"""管理员创建用户请求 DTO"""
email: str = Field(max_length=50)
email: str | None = Field(default=None, max_length=50)
"""用户邮箱"""
password: str
"""用户密码(明文,由服务端加密)"""
password: str | None = None
"""用户密码(明文,由服务端加密;为空则不创建邮箱密码身份"""
nickname: str | None = Field(default=None, max_length=50)
"""昵称"""
@@ -364,15 +388,15 @@ class UserAdminCreateRequest(SQLModelBase):
class UserAdminUpdateRequest(SQLModelBase):
"""管理员更新用户请求 DTO"""
email: str = Field(max_length=50)
email: str | None = Field(default=None, max_length=50)
"""邮箱"""
nickname: str | None = Field(default=None, max_length=50)
"""昵称"""
password: str | None = None
"""新密码(为空则不修改)"""
phone: str | None = None
"""手机号"""
group_id: UUID | None = None
"""用户组UUID"""
@@ -389,9 +413,6 @@ class UserAdminUpdateRequest(SQLModelBase):
group_expires: datetime | None = None
"""用户组过期时间"""
two_factor: str | None = None
"""两步验证密钥32位字符串传 null 可清除,不传则不修改)"""
class UserCalibrateResponse(SQLModelBase):
"""用户存储校准响应 DTO"""
@@ -415,9 +436,6 @@ class UserCalibrateResponse(SQLModelBase):
class UserAdminDetailResponse(UserPublic):
"""管理员用户详情响应 DTO"""
two_factor_enabled: bool = False
"""是否启用两步验证"""
file_count: int = 0
"""文件数量"""
@@ -443,14 +461,14 @@ UserSettingResponse.model_rebuild()
class User(UserBase, UUIDTableBaseMixin):
"""用户模型"""
email: str = Field(max_length=50, unique=True, index=True)
"""用户邮箱,唯一"""
email: str | None = Field(default=None, max_length=50, unique=True, index=True)
"""用户邮箱(社交登录用户可能没有邮箱)"""
nickname: str | None = Field(default=None, max_length=50)
"""用于公开展示的名字,可使用真实姓名或昵称"""
password: str = Field(max_length=255)
"""用户密码(加密后"""
phone: str | None = Field(default=None, max_length=20, unique=True, index=True)
"""手机号(预留"""
status: UserStatus = UserStatus.ACTIVE
"""用户状态"""
@@ -458,9 +476,6 @@ class User(UserBase, UUIDTableBaseMixin):
storage: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, ge=0)
"""已用存储空间(字节)"""
two_factor: str | None = Field(default=None, min_length=32, max_length=32)
"""两步验证密钥"""
avatar: str = Field(default="default", max_length=255)
"""头像地址"""
@@ -533,6 +548,12 @@ class User(UserBase, UUIDTableBaseMixin):
}
)
auth_identities: list["AuthIdentity"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
"""用户的认证身份列表"""
downloads: list["Download"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"cascade": "all, delete-orphan"}
@@ -634,4 +655,3 @@ class User(UserBase, UUIDTableBaseMixin):
filter=filter,
table_view=table_view,
)