feat: 添加 OAuth 认证支持及相关改进

- 新增 OAuth 模块,支持 LinuxDo/GitHub/Google 等第三方登录
- 用户邮箱改为可选字段,支持无邮箱注册
- 新增模块配置验证状态 (config_validated/config_error)
- 系统设置界面改为分块独立保存
- 用户设置新增 OAuth 绑定管理和首次密码设置
- 登录界面支持 OAuth 按钮展示
- 邮箱验证设置移至邮件设置页面
This commit is contained in:
fawney19
2026-01-19 03:19:17 +08:00
parent e2e14fd09c
commit 3d88dfd98a
61 changed files with 4548 additions and 950 deletions

View File

@@ -55,7 +55,7 @@ class LoginResponse(BaseModel):
token_type: str = "bearer"
expires_in: int = 86400 # Token有效期默认24小时
user_id: str
email: str
email: Optional[str] = None
username: str
role: str
@@ -78,14 +78,19 @@ class RefreshTokenResponse(BaseModel):
class RegisterRequest(BaseModel):
"""注册请求"""
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
email: Optional[str] = Field(None, max_length=255, description="邮箱地址(可选)")
username: str = Field(..., min_length=2, max_length=50, description="用户名")
password: str = Field(..., min_length=6, max_length=128, description="密码")
@classmethod
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""验证邮箱格式"""
"""验证邮箱格式(如果提供)"""
if v is None:
return None
v = v.strip()
if not v:
return None
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(email_pattern, v):
raise ValueError("邮箱格式无效")
@@ -121,7 +126,7 @@ class RegisterResponse(BaseModel):
"""注册响应"""
user_id: str
email: str
email: Optional[str] = None
username: str
message: str
@@ -223,6 +228,7 @@ class RegistrationSettingsResponse(BaseModel):
enable_registration: bool
require_email_verification: bool
email_configured: bool = Field(description="是否配置了邮箱服务")
# ========== 用户管理 ==========
@@ -335,7 +341,7 @@ class UserResponse(BaseModel):
"""用户响应"""
id: str
email: str
email: Optional[str] = None
username: str
role: UserRole
allowed_providers: Optional[List[str]] = None # 允许使用的提供商 ID 列表
@@ -699,7 +705,7 @@ class UpdatePreferencesRequest(BaseModel):
class ChangePasswordRequest(BaseModel):
"""修改密码请求"""
old_password: str
old_password: Optional[str] = None # 可选:首次设置密码时不需要
new_password: str

View File

@@ -42,9 +42,13 @@ class User(Base):
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
# OAuth 用户可能没有邮箱Postgres unique 允许多个 NULL
email = Column(String(255), unique=True, index=True, nullable=True)
# 注意:所有创建用户的入口必须显式写入 true/false禁止依赖默认值
email_verified = Column(Boolean, nullable=False)
username = Column(String(100), unique=True, index=True, nullable=False)
password_hash = Column(String(255), nullable=False)
# OAuth 用户可能没有本地密码v1 仅做字段兼容)
password_hash = Column(String(255), nullable=True)
role = Column(
Enum(
UserRole,
@@ -509,6 +513,95 @@ class LDAPConfig(Base):
return crypto_service.decrypt(self.bind_password_encrypted)
class OAuthProvider(Base):
"""OAuth Provider 配置表(按 provider_type 唯一)"""
__tablename__ = "oauth_providers"
# 使用 provider_type 作为主键,便于通过 URL 参数直接定位配置
provider_type = Column(String(50), primary_key=True)
display_name = Column(String(100), nullable=False)
client_id = Column(String(255), nullable=False)
client_secret_encrypted = Column(Text, nullable=True) # 允许 NULL 表示尚未配置/已清除
# 可选覆盖端点(需在业务层做白名单校验)
authorization_url_override = Column(String(500), nullable=True)
token_url_override = Column(String(500), nullable=True)
userinfo_url_override = Column(String(500), nullable=True)
# 可选覆盖 scopesJSON 列表)
scopes = Column(JSON, nullable=True)
# 服务端控制 redirect_uri 与前端回调 URL
redirect_uri = Column(String(500), nullable=False)
frontend_callback_url = Column(String(500), nullable=False)
# Provider 特定配置/映射
attribute_mapping = Column(JSON, nullable=True)
extra_config = Column(JSON, nullable=True)
is_enabled = Column(Boolean, default=False, nullable=False)
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
def set_client_secret(self, secret: str) -> None:
"""设置并加密 client_secret"""
from src.core.crypto import crypto_service
self.client_secret_encrypted = crypto_service.encrypt(secret)
def get_client_secret(self) -> str:
"""获取解密后的 client_secret未配置时返回空串"""
from src.core.crypto import crypto_service
if not self.client_secret_encrypted:
return ""
return crypto_service.decrypt(self.client_secret_encrypted)
class UserOAuthLink(Base):
"""用户与 OAuth Provider 的绑定关系"""
__tablename__ = "user_oauth_links"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
provider_type = Column(
String(50),
ForeignKey("oauth_providers.provider_type", ondelete="CASCADE"),
nullable=False,
index=True,
)
provider_user_id = Column(String(255), nullable=False)
provider_username = Column(String(255), nullable=True)
provider_email = Column(String(255), nullable=True)
extra_data = Column(JSON, nullable=True)
linked_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
last_login_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("provider_type", "provider_user_id", name="uq_oauth_provider_user"),
UniqueConstraint("user_id", "provider_type", name="uq_user_oauth_provider"),
)
class Provider(Base):
"""提供商配置表"""