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:
@@ -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
139
sqlmodels/auth_identity.py
Normal 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
@@ -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 key(DEFAULT 类型时为 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 类型为枚举类型)"""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user