diff --git a/routers/api/v1/admin/user/__init__.py b/routers/api/v1/admin/user/__init__.py index 8f20806..f26df50 100644 --- a/routers/api/v1/admin/user/__init__.py +++ b/routers/api/v1/admin/user/__init__.py @@ -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( diff --git a/routers/api/v1/site/__init__.py b/routers/api/v1/site/__init__.py index 1ec446d..3e5206d 100644 --- a/routers/api/v1/site/__init__.py +++ b/routers/api/v1/site/__init__.py @@ -4,7 +4,9 @@ from middleware.dependencies import SessionDep from sqlmodels import ( ResponseBase, Setting, SettingsType, SiteConfigResponse, ThemePreset, ThemePresetResponse, ThemePresetListResponse, + AuthMethodConfig, ) +from sqlmodels.auth_identity import AuthProviderType from sqlmodels.setting import CaptchaType from utils import http_exceptions @@ -70,7 +72,7 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse: 获取站点全局配置 无需认证。前端在初始化时调用此端点获取验证码类型、 - 登录/注册/找回密码是否需要验证码等配置。 + 登录/注册/找回密码是否需要验证码、可用的认证方式等配置。 """ # 批量查询所需设置 settings: list[Setting] = await Setting.get( @@ -78,7 +80,9 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse: (Setting.type == SettingsType.BASIC) | (Setting.type == SettingsType.LOGIN) | (Setting.type == SettingsType.REGISTER) | - (Setting.type == SettingsType.CAPTCHA), + (Setting.type == SettingsType.CAPTCHA) | + (Setting.type == SettingsType.AUTH) | + (Setting.type == SettingsType.OAUTH), fetch_mode="all", ) @@ -94,6 +98,16 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse: elif captcha_type == CaptchaType.CLOUD_FLARE_TURNSTILE: captcha_key = s.get("captcha_CloudflareKey") or None + # 构建认证方式列表 + auth_methods: list[AuthMethodConfig] = [ + AuthMethodConfig(provider=AuthProviderType.EMAIL_PASSWORD, is_enabled=s.get("auth_email_password_enabled") == "1"), + AuthMethodConfig(provider=AuthProviderType.PHONE_SMS, is_enabled=s.get("auth_phone_sms_enabled") == "1"), + AuthMethodConfig(provider=AuthProviderType.GITHUB, is_enabled=s.get("github_enabled") == "1"), + AuthMethodConfig(provider=AuthProviderType.QQ, is_enabled=s.get("qq_enabled") == "1"), + AuthMethodConfig(provider=AuthProviderType.PASSKEY, is_enabled=s.get("auth_passkey_enabled") == "1"), + AuthMethodConfig(provider=AuthProviderType.MAGIC_LINK, is_enabled=s.get("auth_magic_link_enabled") == "1"), + ] + return SiteConfigResponse( title=s.get("siteName") or "DiskNext", register_enabled=s.get("register_enabled") == "1", @@ -102,4 +116,11 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse: forget_captcha=s.get("forget_captcha") == "1", captcha_type=captcha_type, captcha_key=captcha_key, + auth_methods=auth_methods, + password_required=s.get("auth_password_required") == "1", + phone_binding_required=s.get("auth_phone_binding_required") == "1", + email_binding_required=s.get("auth_email_binding_required") == "1", + footer_code=s.get("footer_code"), + tos_url=s.get("tos_url"), + privacy_url=s.get("privacy_url"), ) \ No newline at end of file diff --git a/routers/api/v1/user/__init__.py b/routers/api/v1/user/__init__.py index 44abda3..cb89015 100644 --- a/routers/api/v1/user/__init__.py +++ b/routers/api/v1/user/__init__.py @@ -2,7 +2,8 @@ from typing import Annotated, Literal from uuid import UUID, uuid4 import jwt -from fastapi import APIRouter, Depends, Form, HTTPException +from fastapi import APIRouter, Depends, HTTPException +from itsdangerous import URLSafeTimedSerializer from loguru import logger from webauthn import generate_registration_options from webauthn.helpers import options_to_json_dict @@ -12,6 +13,7 @@ import sqlmodels from middleware.auth import auth_required from middleware.dependencies import SessionDep, require_captcha from service.captcha import CaptchaScene +from sqlmodels.auth_identity import AuthIdentity, AuthProviderType from sqlmodels.user import UserStatus from utils import JWT, Password, http_exceptions from .settings import user_settings_router @@ -23,59 +25,36 @@ user_router = APIRouter( user_router.include_router(user_settings_router) -class OAuth2PasswordWithExtrasForm: - """ - 扩展 OAuth2 密码表单。 - - 在标准 username/password 基础上添加 otp_code 字段。 - captcha_code 由 require_captcha 依赖注入单独处理。 - """ - - def __init__( - self, - *, - username: Annotated[str, Form()], - password: Annotated[str, Form()], - otp_code: Annotated[str | None, Form(min_length=6, max_length=6)] = None, - ): - self.username = username - self.password = password - self.otp_code = otp_code - @user_router.post( path='/session', - summary='用户登录', - description='用户登录端点,支持验证码校验和两步验证。', - dependencies=[Depends(require_captcha(CaptchaScene.LOGIN))], + summary='用户登录(统一入口)', + description='统一登录端点,支持多种认证方式。', ) async def router_user_session( session: SessionDep, - form_data: Annotated[OAuth2PasswordWithExtrasForm, Depends()], + request: sqlmodels.UnifiedLoginRequest, ) -> sqlmodels.TokenResponse: """ - 用户登录端点 + 统一登录端点 - 表单字段: - - username: 用户邮箱 - - password: 用户密码 - - captcha_code: 验证码 token(可选,由 require_captcha 依赖校验) - - otp_code: 两步验证码(可选,仅在用户启用 2FA 时需要) + 请求体: + - provider: 登录方式(email_password / github / qq / passkey / magic_link) + - identifier: 标识符(邮箱 / OAuth code / credential_id / magic link token) + - credential: 凭证(密码 / WebAuthn assertion 等) + - two_fa_code: 两步验证码(可选) + - redirect_uri: OAuth 回调地址(可选) + - captcha: 验证码(可选) 错误处理: - - 400: 需要验证码但未提供 - - 401: 邮箱/密码错误,或 2FA 验证码错误 - - 403: 账户已禁用 / 验证码验证失败 - - 428: 需要两步验证但未提供 otp_code + - 400: 登录方式未启用 / 参数错误 + - 401: 凭证错误 + - 403: 账户已禁用 + - 428: 需要两步验证 + - 501: 暂未实现的登录方式 """ - return await service.user.login( - session, - sqlmodels.LoginRequest( - email=form_data.username, - password=form_data.password, - two_fa_code=form_data.otp_code, - ), - ) + return await service.user.unified_login(session, request) + @user_router.post( path='/session/refresh', @@ -150,41 +129,82 @@ async def router_user_session_refresh( @user_router.post( path='/', - summary='用户注册', + summary='用户注册(统一入口)', description='User registration endpoint.', status_code=204, ) async def router_user_register( session: SessionDep, - request: sqlmodels.RegisterRequest, + request: sqlmodels.UnifiedRegisterRequest, ) -> None: """ - 用户注册端点 + 统一注册端点 流程: - 1. 验证用户名唯一性 - 2. 获取默认用户组 - 3. 创建用户记录 - 4. 创建用户根目录(name="/") + 1. 检查注册开关 + 2. 检查 provider 启用 + 3. 验证 identifier 唯一性(AuthIdentity 表) + 4. 创建 User + AuthIdentity + 根目录 - :param session: 数据库会话 - :param request: 注册请求 - :return: 注册结果 - :raises HTTPException 400: 用户名已存在 - :raises HTTPException 500: 默认用户组或存储策略不存在 + 请求体: + - provider: 注册方式(email_password / phone_sms) + - identifier: 标识符(邮箱 / 手机号) + - credential: 凭证(密码 / 短信验证码) + - nickname: 昵称(可选) + - captcha: 验证码(可选) + + 错误处理: + - 400: 注册未开放 / 参数错误 + - 409: 邮箱或手机号已存在 + - 501: 暂未实现的注册方式 """ - # 1. 验证邮箱唯一性 + # 1. 检查注册开关 + register_setting = await sqlmodels.Setting.get( + session, + (sqlmodels.Setting.type == sqlmodels.SettingsType.REGISTER) + & (sqlmodels.Setting.name == "register_enabled"), + ) + if not register_setting or register_setting.value != "1": + http_exceptions.raise_bad_request("注册功能未开放") + + # 2. 目前只支持 email_password 注册 + if request.provider == AuthProviderType.PHONE_SMS: + http_exceptions.raise_not_implemented("短信注册暂未开放") + elif request.provider != AuthProviderType.EMAIL_PASSWORD: + http_exceptions.raise_bad_request("不支持的注册方式") + + # 3. 检查密码是否必填 + password_required_setting = await sqlmodels.Setting.get( + session, + (sqlmodels.Setting.type == sqlmodels.SettingsType.AUTH) + & (sqlmodels.Setting.name == "auth_password_required"), + ) + is_password_required = not password_required_setting or password_required_setting.value != "0" + if is_password_required and not request.credential: + http_exceptions.raise_bad_request("密码不能为空") + + # 4. 验证 identifier 唯一性(AuthIdentity 表) + existing_identity = await AuthIdentity.get( + session, + (AuthIdentity.provider == request.provider) + & (AuthIdentity.identifier == request.identifier), + ) + if existing_identity: + raise HTTPException(status_code=409, detail="该邮箱已被注册") + + # 同时检查 User.email 唯一性(防止旧数据冲突) existing_user = await sqlmodels.User.get( session, - sqlmodels.User.email == request.email + sqlmodels.User.email == request.identifier, ) if existing_user: - raise HTTPException(status_code=400, detail="邮箱已存在") + raise HTTPException(status_code=409, detail="该邮箱已被注册") - # 2. 获取默认用户组(从设置中读取 UUID) - default_group_setting: sqlmodels.Setting | None = await sqlmodels.Setting.get( + # 5. 获取默认用户组 + default_group_setting = await sqlmodels.Setting.get( session, - (sqlmodels.Setting.type == sqlmodels.SettingsType.REGISTER) & (sqlmodels.Setting.name == "default_group") + (sqlmodels.Setting.type == sqlmodels.SettingsType.REGISTER) + & (sqlmodels.Setting.name == "default_group"), ) if default_group_setting is None or not default_group_setting.value: logger.error("默认用户组不存在") @@ -196,17 +216,28 @@ async def router_user_register( logger.error("默认用户组不存在") http_exceptions.raise_internal_error() - # 3. 创建用户 - hashed_password = Password.hash(request.password) + # 6. 创建用户 new_user = sqlmodels.User( - email=request.email, - password=hashed_password, + email=request.identifier, + nickname=request.nickname, group_id=default_group.id, ) new_user_id = new_user.id await new_user.save(session) - # 4. 创建用户根目录 + # 7. 创建 AuthIdentity + hashed_password = Password.hash(request.credential) if request.credential else None + identity = AuthIdentity( + provider=AuthProviderType.EMAIL_PASSWORD, + identifier=request.identifier, + credential=hashed_password, + is_primary=True, + is_verified=False, + user_id=new_user_id, + ) + await identity.save(session) + + # 8. 创建用户根目录 default_policy = await sqlmodels.Policy.get(session, sqlmodels.Policy.name == "本地存储") if not default_policy: logger.error("默认存储策略不存在") @@ -220,6 +251,66 @@ async def router_user_register( policy_id=default_policy.id, ).save(session) + +@user_router.post( + path='/magic-link', + summary='发送 Magic Link 邮件', + description='生成 Magic Link token 并发送到指定邮箱。', + status_code=204, +) +async def router_user_magic_link( + session: SessionDep, + request: sqlmodels.MagicLinkRequest, +) -> None: + """ + 发送 Magic Link 邮件 + + 流程: + 1. 验证邮箱对应的 AuthIdentity 存在 + 2. 生成签名 token + 3. 发送邮件(包含带 token 的链接) + + 错误处理: + - 400: Magic Link 未启用 + - 404: 邮箱未注册 + """ + # 检查 magic_link 是否启用 + magic_link_setting = await sqlmodels.Setting.get( + session, + (sqlmodels.Setting.type == sqlmodels.SettingsType.AUTH) + & (sqlmodels.Setting.name == "auth_magic_link_enabled"), + ) + if not magic_link_setting or magic_link_setting.value != "1": + http_exceptions.raise_bad_request("Magic Link 登录未启用") + + # 验证邮箱存在 + identity = await AuthIdentity.get( + session, + (AuthIdentity.identifier == request.email) + & ( + (AuthIdentity.provider == AuthProviderType.EMAIL_PASSWORD) + | (AuthIdentity.provider == AuthProviderType.MAGIC_LINK) + ), + ) + if not identity: + http_exceptions.raise_not_found("该邮箱未注册") + + # 生成签名 token + serializer = URLSafeTimedSerializer(JWT.SECRET_KEY) + token = serializer.dumps(request.email, salt="magic-link-salt") + + # 获取站点 URL + site_url_setting = await sqlmodels.Setting.get( + session, + (sqlmodels.Setting.type == sqlmodels.SettingsType.BASIC) + & (sqlmodels.Setting.name == "siteURL"), + ) + site_url = site_url_setting.value if site_url_setting else "http://localhost" + + # TODO: 发送邮件(包含 {site_url}/auth/magic-link?token={token}) + logger.info(f"Magic Link token 已生成: {token} (邮件发送待实现)") + + @user_router.post( path='/code', summary='发送验证码邮件', @@ -230,52 +321,12 @@ def router_user_email_code( ) -> sqlmodels.ResponseBase: """ Send a verification code email. - + Returns: dict: A dictionary containing information about the password reset email. """ http_exceptions.raise_not_implemented() -@user_router.get( - path='/qq', - summary='初始化QQ登录', - description='Initialize QQ login for a user.', -) -def router_user_qq() -> sqlmodels.ResponseBase: - """ - Initialize QQ login for a user. - - Returns: - dict: A dictionary containing QQ login initialization information. - """ - http_exceptions.raise_not_implemented() - -@user_router.get( - path='authn/{username}', - summary='WebAuthn登录初始化', - description='Initialize WebAuthn login for a user.', -) -async def router_user_authn(username: str) -> sqlmodels.ResponseBase: - - http_exceptions.raise_not_implemented() - -@user_router.post( - path='authn/finish/{username}', - summary='WebAuthn登录', - description='Finish WebAuthn login for a user.', -) -def router_user_authn_finish(username: str) -> sqlmodels.ResponseBase: - """ - Finish WebAuthn login for a user. - - Args: - username (str): The username of the user. - - Returns: - dict: A dictionary containing WebAuthn login information. - """ - http_exceptions.raise_not_implemented() - @user_router.get( path='/profile/{id}', summary='获取用户主页展示用分享', @@ -284,10 +335,10 @@ def router_user_authn_finish(username: str) -> sqlmodels.ResponseBase: def router_user_profile(id: str) -> sqlmodels.ResponseBase: """ Get user profile for display. - + Args: id (str): The user ID. - + Returns: dict: A dictionary containing user profile information. """ @@ -301,11 +352,11 @@ def router_user_profile(id: str) -> sqlmodels.ResponseBase: def router_user_avatar(id: str, size: int = 128) -> sqlmodels.ResponseBase: """ Get user avatar by ID and size. - + Args: id (str): The user ID. size (int): The size of the avatar image. - + Returns: str: A Base64 encoded string of the user avatar image. """ @@ -348,8 +399,6 @@ async def router_user_me( return sqlmodels.UserResponse( id=user.id, email=user.email, - status=user.status, - score=user.score, nickname=user.nickname, avatar=user.avatar, created_at=user.created_at, @@ -374,9 +423,9 @@ async def router_user_storage( group = await sqlmodels.Group.get(session, sqlmodels.Group.id == user.group_id) if not group: raise HTTPException(status_code=404, detail="用户组不存在") - + # [TODO] 总空间加上用户购买的额外空间 - + total: int = group.max_storage used: int = user.storage free: int = max(0, total - used) @@ -389,8 +438,8 @@ async def router_user_storage( @user_router.put( path='/authn/start', - summary='WebAuthn登录初始化', - description='Initialize WebAuthn login for a user.', + summary='注册 Passkey 凭证(初始化)', + description='Initialize Passkey registration for a user.', dependencies=[Depends(auth_required)], ) async def router_user_authn_start( @@ -398,18 +447,19 @@ async def router_user_authn_start( user: Annotated[sqlmodels.user.User, Depends(auth_required)], ) -> sqlmodels.ResponseBase: """ - Initialize WebAuthn login for a user. + Passkey 注册初始化(需要登录) - Returns: - dict: A dictionary containing WebAuthn initialization information. + 返回 WebAuthn registration options,前端使用 navigator.credentials.create() 处理。 + + 错误处理: + - 400: Passkey 未启用 """ - # TODO: 检查 WebAuthn 是否开启,用户是否有注册过 WebAuthn 设备等 authn_setting = await sqlmodels.Setting.get( session, (sqlmodels.Setting.type == "authn") & (sqlmodels.Setting.name == "authn_enabled") ) if not authn_setting or authn_setting.value != "1": - raise HTTPException(status_code=400, detail="WebAuthn is not enabled") + raise HTTPException(status_code=400, detail="Passkey 未启用") site_url_setting = await sqlmodels.Setting.get( session, @@ -423,23 +473,26 @@ async def router_user_authn_start( options = generate_registration_options( rp_id=site_url_setting.value if site_url_setting else "", rp_name=site_title_setting.value if site_title_setting else "", - user_name=user.email, - user_display_name=user.nickname or user.email, + user_name=user.email or str(user.id), + user_display_name=user.nickname or user.email or str(user.id), ) return sqlmodels.ResponseBase(data=options_to_json_dict(options)) @user_router.put( path='/authn/finish', - summary='WebAuthn登录', - description='Finish WebAuthn login for a user.', + summary='注册 Passkey 凭证(完成)', + description='Finish Passkey registration for a user.', dependencies=[Depends(auth_required)], ) def router_user_authn_finish() -> sqlmodels.ResponseBase: """ - Finish WebAuthn login for a user. - + Passkey 注册完成(需要登录) + + 接收前端 navigator.credentials.create() 返回的凭证数据, + 创建 UserAuthn 行 + AuthIdentity(provider=passkey)。 + Returns: - dict: A dictionary containing WebAuthn login information. + dict: A dictionary containing Passkey registration information. """ - http_exceptions.raise_not_implemented() \ No newline at end of file + http_exceptions.raise_not_implemented() diff --git a/routers/api/v1/user/settings/__init__.py b/routers/api/v1/user/settings/__init__.py index 75b5738..534b6bb 100644 --- a/routers/api/v1/user/settings/__init__.py +++ b/routers/api/v1/user/settings/__init__.py @@ -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_token(GET /2fa 返回的令牌)和 code(6 位验证码)。 + 将 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) \ No newline at end of file + # 将 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) diff --git a/service/user/__init__.py b/service/user/__init__.py index ee9b18b..5c7bd9a 100644 --- a/service/user/__init__.py +++ b/service/user/__init__.py @@ -1 +1 @@ -from .login import login \ No newline at end of file +from .login import unified_login diff --git a/service/user/login.py b/service/user/login.py index ee0ad68..7515f3b 100644 --- a/service/user/login.py +++ b/service/user/login.py @@ -1,83 +1,417 @@ -from uuid import uuid4 +""" +统一登录服务 -from loguru import logger +支持多种认证方式:邮箱密码、GitHub OAuth、QQ OAuth、Passkey、Magic Link、手机短信(预留)。 +""" +from uuid import UUID, uuid4 -from middleware.dependencies import SessionDep -from sqlmodels import LoginRequest, TokenResponse, User +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired +from loguru import logger as l +from sqlmodel.ext.asyncio.session import AsyncSession + +from sqlmodels.auth_identity import AuthIdentity, AuthProviderType from sqlmodels.group import GroupClaims, GroupOptions -from sqlmodels.user import UserStatus -from utils import http_exceptions -from utils.JWT import create_access_token, create_refresh_token +from sqlmodels.object import Object, ObjectType +from sqlmodels.policy import Policy +from sqlmodels.setting import Setting, SettingsType +from sqlmodels.user import TokenResponse, UnifiedLoginRequest, User, UserStatus +from utils import JWT, http_exceptions from utils.password.pwd import Password, PasswordStatus -async def login( - session: SessionDep, - login_request: LoginRequest, +async def unified_login( + session: AsyncSession, + request: UnifiedLoginRequest, ) -> TokenResponse: """ - 根据账号密码进行登录。 - 如果登录成功,返回一个 TokenResponse 对象,包含访问令牌和刷新令牌以及它们的过期时间。 + 统一登录入口,根据 provider 分发到不同的登录逻辑。 :param session: 数据库会话 - :param login_request: 登录请求 - - :return: TokenResponse 对象或状态码或 None + :param request: 统一登录请求 + :return: TokenResponse """ - # 获取用户信息(预加载 group 关系) - current_user: User = await User.get( + await _check_provider_enabled(session, request.provider) + + match request.provider: + case AuthProviderType.EMAIL_PASSWORD: + user = await _login_email_password(session, request) + case AuthProviderType.GITHUB: + user = await _login_oauth(session, request, AuthProviderType.GITHUB) + case AuthProviderType.QQ: + user = await _login_oauth(session, request, AuthProviderType.QQ) + case AuthProviderType.PASSKEY: + user = await _login_passkey(session, request) + case AuthProviderType.MAGIC_LINK: + user = await _login_magic_link(session, request) + case AuthProviderType.PHONE_SMS: + http_exceptions.raise_not_implemented("短信登录暂未开放") + case _: + http_exceptions.raise_bad_request(f"不支持的登录方式: {request.provider}") + + return await _issue_tokens(session, user) + + +async def _check_provider_enabled(session: AsyncSession, provider: AuthProviderType) -> None: + """检查认证方式是否已被站长启用""" + # OAuth 类型从 OAUTH 设置中查询 + if provider in (AuthProviderType.GITHUB, AuthProviderType.QQ): + setting_name = f"{provider.value}_enabled" + setting = await Setting.get( + session, + (Setting.type == SettingsType.OAUTH) & (Setting.name == setting_name), + ) + if not setting or setting.value != "1": + http_exceptions.raise_bad_request(f"登录方式 {provider.value} 未启用") + return + + # 其他类型从 AUTH 设置中查询 + setting_name = f"auth_{provider.value}_enabled" + setting = await Setting.get( session, - User.email == login_request.email, - fetch_mode="first", - load=User.group, - ) #type: ignore + (Setting.type == SettingsType.AUTH) & (Setting.name == setting_name), + ) + if not setting or setting.value != "1": + http_exceptions.raise_bad_request(f"登录方式 {provider.value} 未启用") - # 验证用户是否存在 - if not current_user: - logger.debug(f"Cannot find user with email: {login_request.email}") - http_exceptions.raise_unauthorized("Invalid email or password") - # 验证密码是否正确 - if Password.verify(current_user.password, login_request.password) != PasswordStatus.VALID: - logger.debug(f"Password verification failed for user: {login_request.email}") - http_exceptions.raise_unauthorized("Invalid email or password") +async def _login_email_password( + session: AsyncSession, + request: UnifiedLoginRequest, +) -> User: + """邮箱+密码登录""" + if not request.credential: + http_exceptions.raise_bad_request("密码不能为空") - # 验证用户是否可登录(修复:显式枚举比较,StrEnum 永远 truthy) - if current_user.status != UserStatus.ACTIVE: - http_exceptions.raise_forbidden("Your account is disabled") + # 查找 AuthIdentity + identity: AuthIdentity | None = await AuthIdentity.get( + session, + (AuthIdentity.provider == AuthProviderType.EMAIL_PASSWORD) + & (AuthIdentity.identifier == request.identifier), + ) + if not identity: + l.debug(f"未找到邮箱密码身份: {request.identifier}") + http_exceptions.raise_unauthorized("邮箱或密码错误") - # 检查两步验证 - if current_user.two_factor: - # 用户已启用两步验证 - if not login_request.two_fa_code: - logger.debug(f"2FA required for user: {login_request.email}") - http_exceptions.raise_precondition_required("2FA required") + # 验证密码 + if not identity.credential: + http_exceptions.raise_unauthorized("邮箱或密码错误") - # 验证 OTP 码 - if Password.verify_totp(current_user.two_factor, login_request.two_fa_code) != PasswordStatus.VALID: - logger.debug(f"Invalid 2FA code for user: {login_request.email}") - http_exceptions.raise_unauthorized("Invalid 2FA code") + if Password.verify(identity.credential, request.credential) != PasswordStatus.VALID: + l.debug(f"密码验证失败: {request.identifier}") + http_exceptions.raise_unauthorized("邮箱或密码错误") + # 加载用户 + user: User = await User.get(session, User.id == identity.user_id, load=User.group) + if not user: + http_exceptions.raise_unauthorized("用户不存在") + + # 验证用户状态 + if user.status != UserStatus.ACTIVE: + http_exceptions.raise_forbidden("账户已被禁用") + + # 检查两步验证(从 AuthIdentity.extra_data 中读取 2FA secret) + if identity.extra_data: + import orjson + extra: dict = orjson.loads(identity.extra_data) + two_factor_secret: str | None = extra.get("two_factor") + if two_factor_secret: + if not request.two_fa_code: + l.debug(f"需要两步验证: {request.identifier}") + http_exceptions.raise_precondition_required("需要两步验证") + if Password.verify_totp(two_factor_secret, request.two_fa_code) != PasswordStatus.VALID: + l.debug(f"两步验证失败: {request.identifier}") + http_exceptions.raise_unauthorized("两步验证码错误") + + return user + + +async def _login_oauth( + session: AsyncSession, + request: UnifiedLoginRequest, + provider: AuthProviderType, +) -> User: + """ + OAuth 登录(GitHub / QQ) + + identifier 为 OAuth authorization code,后端换取 access_token 再获取用户信息。 + """ + # 读取 OAuth 配置 + client_id_setting = await Setting.get( + session, + (Setting.type == SettingsType.OAUTH) & (Setting.name == f"{provider.value}_client_id"), + ) + client_secret_setting = await Setting.get( + session, + (Setting.type == SettingsType.OAUTH) & (Setting.name == f"{provider.value}_client_secret"), + ) + if not client_id_setting or not client_secret_setting: + http_exceptions.raise_bad_request(f"{provider.value} OAuth 未配置") + + client_id = client_id_setting.value or "" + client_secret = client_secret_setting.value or "" + + # 根据 provider 创建对应的 OAuth 客户端 + if provider == AuthProviderType.GITHUB: + from service.oauth import GithubOAuth + oauth_client = GithubOAuth(client_id, client_secret) + token_resp = await oauth_client.get_access_token(code=request.identifier) + user_info_resp = await oauth_client.get_user_info(token_resp) + openid = str(user_info_resp.user_data.id) + nickname = user_info_resp.user_data.name or user_info_resp.user_data.login + avatar_url = user_info_resp.user_data.avatar_url + email = user_info_resp.user_data.email + elif provider == AuthProviderType.QQ: + from service.oauth import QQOAuth + oauth_client = QQOAuth(client_id, client_secret) + token_resp = await oauth_client.get_access_token( + code=request.identifier, + redirect_uri=request.redirect_uri or "", + ) + openid_resp = await oauth_client.get_openid(token_resp.access_token) + user_info_resp = await oauth_client.get_user_info( + token_resp, + app_id=client_id, + openid=openid_resp.openid, + ) + openid = openid_resp.openid + nickname = user_info_resp.user_data.nickname + avatar_url = user_info_resp.user_data.figureurl_qq_2 or user_info_resp.user_data.figureurl_2 + email = None + else: + http_exceptions.raise_bad_request(f"不支持的 OAuth 提供者: {provider.value}") + + # 查找已有 AuthIdentity + identity: AuthIdentity | None = await AuthIdentity.get( + session, + (AuthIdentity.provider == provider) & (AuthIdentity.identifier == openid), + ) + + if identity: + # 已绑定 → 更新 OAuth 信息并返回关联用户 + identity.display_name = nickname + identity.avatar_url = avatar_url + await identity.save(session) + + user: User = await User.get(session, User.id == identity.user_id, load=User.group) + if not user: + http_exceptions.raise_unauthorized("用户不存在") + if user.status != UserStatus.ACTIVE: + http_exceptions.raise_forbidden("账户已被禁用") + return user + + # 未绑定 → 自动注册 + user = await _auto_register_oauth_user( + session, + provider=provider, + openid=openid, + nickname=nickname, + avatar_url=avatar_url, + email=email, + ) + return user + + +async def _auto_register_oauth_user( + session: AsyncSession, + *, + provider: AuthProviderType, + openid: str, + nickname: str | None, + avatar_url: str | None, + email: str | None, +) -> User: + """OAuth 自动注册用户""" + # 获取默认用户组 + default_group_setting = await Setting.get( + session, + (Setting.type == SettingsType.REGISTER) & (Setting.name == "default_group"), + ) + if not default_group_setting or not default_group_setting.value: + l.error("默认用户组未配置") + http_exceptions.raise_internal_error() + + default_group_id = UUID(default_group_setting.value) + + # 创建用户 + new_user = User( + email=email, + nickname=nickname, + avatar=avatar_url or "default", + group_id=default_group_id, + ) + new_user_id = new_user.id + new_user = await new_user.save(session) + + # 创建 AuthIdentity + identity = AuthIdentity( + provider=provider, + identifier=openid, + display_name=nickname, + avatar_url=avatar_url, + is_primary=True, + is_verified=True, + user_id=new_user_id, + ) + await identity.save(session) + + # 创建用户根目录 + default_policy = await Policy.get(session, Policy.name == "本地存储") + if default_policy: + await Object( + name="/", + type=ObjectType.FOLDER, + owner_id=new_user_id, + parent_id=None, + policy_id=default_policy.id, + ).save(session) + + # 重新加载用户(含 group 关系) + user: User = await User.get(session, User.id == new_user_id, load=User.group) + l.info(f"OAuth 自动注册用户: provider={provider.value}, openid={openid}") + return user + + +async def _login_passkey( + session: AsyncSession, + request: UnifiedLoginRequest, +) -> User: + """ + Passkey/WebAuthn 登录 + + identifier 为 credential_id,credential 为 JSON 格式的 authenticator assertion response。 + """ + from webauthn import verify_authentication_response + from webauthn.helpers.structs import AuthenticationCredential + + if not request.credential: + http_exceptions.raise_bad_request("WebAuthn assertion response 不能为空") + + # 查找 AuthIdentity + identity: AuthIdentity | None = await AuthIdentity.get( + session, + (AuthIdentity.provider == AuthProviderType.PASSKEY) + & (AuthIdentity.identifier == request.identifier), + ) + if not identity: + http_exceptions.raise_unauthorized("Passkey 凭证未注册") + + # 加载对应的 UserAuthn 记录 + from sqlmodels.user_authn import UserAuthn + authn: UserAuthn | None = await UserAuthn.get( + session, + UserAuthn.credential_id == request.identifier, + ) + if not authn: + http_exceptions.raise_unauthorized("Passkey 凭证数据不存在") + + # 获取 RP ID + site_url_setting = await Setting.get( + session, + (Setting.type == SettingsType.BASIC) & (Setting.name == "siteURL"), + ) + rp_id = site_url_setting.value if site_url_setting else "localhost" + + # 验证 WebAuthn assertion + import orjson + credential = AuthenticationCredential.model_validate(orjson.loads(request.credential)) + + try: + verification = verify_authentication_response( + credential=credential, + expected_rp_id=rp_id, + expected_origin=f"https://{rp_id}", + expected_challenge=b"", # TODO: 从 session/cache 中获取 challenge + credential_public_key=bytes.fromhex(authn.credential_public_key), + credential_current_sign_count=authn.sign_count, + ) + except Exception as e: + l.warning(f"WebAuthn 验证失败: {e}") + http_exceptions.raise_unauthorized("Passkey 验证失败") + + # 更新签名计数 + authn.sign_count = verification.new_sign_count + await authn.save(session) + + # 加载用户 + user: User = await User.get(session, User.id == identity.user_id, load=User.group) + if not user: + http_exceptions.raise_unauthorized("用户不存在") + if user.status != UserStatus.ACTIVE: + http_exceptions.raise_forbidden("账户已被禁用") + + return user + + +async def _login_magic_link( + session: AsyncSession, + request: UnifiedLoginRequest, +) -> User: + """ + Magic Link 登录 + + identifier 为签名 token,由 itsdangerous 生成。 + """ + serializer = URLSafeTimedSerializer(JWT.SECRET_KEY) + + try: + email = serializer.loads(request.identifier, salt="magic-link-salt", max_age=600) + except SignatureExpired: + http_exceptions.raise_unauthorized("Magic Link 已过期") + except BadSignature: + http_exceptions.raise_unauthorized("Magic Link 无效") + + # 查找绑定了该邮箱的 AuthIdentity(email_password 或 magic_link) + identity: AuthIdentity | None = await AuthIdentity.get( + session, + (AuthIdentity.identifier == email) + & ( + (AuthIdentity.provider == AuthProviderType.EMAIL_PASSWORD) + | (AuthIdentity.provider == AuthProviderType.MAGIC_LINK) + ), + ) + if not identity: + http_exceptions.raise_unauthorized("该邮箱未注册") + + user: User = await User.get(session, User.id == identity.user_id, load=User.group) + if not user: + http_exceptions.raise_unauthorized("用户不存在") + if user.status != UserStatus.ACTIVE: + http_exceptions.raise_forbidden("账户已被禁用") + + # 标记邮箱已验证 + if not identity.is_verified: + identity.is_verified = True + await identity.save(session) + + return user + + +async def _issue_tokens(session: AsyncSession, user: User) -> TokenResponse: + """ + 签发 JWT 双令牌(access + refresh) + + 提取自原 login.py 的签发逻辑,供所有 provider 共用。 + """ # 加载 GroupOptions group_options: GroupOptions | None = await GroupOptions.get( session, - GroupOptions.group_id == current_user.group_id, + GroupOptions.group_id == user.group_id, ) # 构建权限快照 - current_user.group.options = group_options - group_claims = GroupClaims.from_group(current_user.group) + user.group.options = group_options + group_claims = GroupClaims.from_group(user.group) # 创建令牌 - access_token = create_access_token( - sub=current_user.id, + access_token = JWT.create_access_token( + sub=user.id, jti=uuid4(), - status=current_user.status.value, + status=user.status.value, group=group_claims, ) - refresh_token = create_refresh_token( - sub=current_user.id, - jti=uuid4() + refresh_token = JWT.create_refresh_token( + sub=user.id, + jti=uuid4(), ) return TokenResponse( diff --git a/sqlmodels/__init__.py b/sqlmodels/__init__.py index fd5749d..c0b1246 100644 --- a/sqlmodels/__init__.py +++ b/sqlmodels/__init__.py @@ -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 \ No newline at end of file +from .mixin import ListResponse diff --git a/sqlmodels/auth_identity.py b/sqlmodels/auth_identity.py new file mode 100644 index 0000000..5649f43 --- /dev/null +++ b/sqlmodels/auth_identity.py @@ -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, + ) diff --git a/sqlmodels/migration.py b/sqlmodels/migration.py index 2a49509..7715265 100644 --- a/sqlmodels/migration.py +++ b/sqlmodels/migration.py @@ -28,7 +28,8 @@ default_settings: list[Setting] = [ Setting(name="siteKeywords", value="网盘,网盘", type=SettingsType.BASIC), Setting(name="siteDes", value="DiskNext", type=SettingsType.BASIC), Setting(name="siteTitle", value="云星启智", type=SettingsType.BASIC), - Setting(name="site_notice", value="", type=SettingsType.BASIC), + Setting(name="site_notice_public", value="", type=SettingsType.BASIC), + Setting(name="site_notice_user", value="", type=SettingsType.BASIC), Setting(name="footer_code", value="", type=SettingsType.BASIC), Setting(name="tos_url", value="", type=SettingsType.BASIC), Setting(name="privacy_url", value="", type=SettingsType.BASIC), @@ -58,7 +59,7 @@ default_settings: list[Setting] = [ Setting(name="login_captcha", value="0", type=SettingsType.LOGIN), Setting(name="reg_captcha", value="0", type=SettingsType.LOGIN), Setting(name="reg_email_captcha", value="0", type=SettingsType.LOGIN), - Setting(name="email_active", value="0", type=SettingsType.REGISTER), + Setting(name="require_active", value="0", type=SettingsType.REGISTER), Setting(name="mail_activation_template", value="""