refactor(auth): configure external service adapters
This commit is contained in:
@@ -7,10 +7,12 @@ app.dependencies and authentication behavior lives in application use cases.
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_user_repository
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore
|
||||
from packages.adapters.smtp import NoopEmailService
|
||||
from packages.application.auth.login_use_case import LoginRequest as LoginUseCaseRequest
|
||||
from packages.application.auth.login_use_case import LoginUseCase
|
||||
from packages.application.auth.password_reset_use_case import RequestPasswordResetRequest as PasswordResetUseCaseRequest
|
||||
@@ -85,11 +87,12 @@ class MessageResponse(BaseModel):
|
||||
async def register(
|
||||
request: RegisterRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
email_service=Depends(get_auth_email_service),
|
||||
):
|
||||
use_case = RegisterUserUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=_NoopEmailService(),
|
||||
email_service=email_service,
|
||||
)
|
||||
response, error = use_case.execute(
|
||||
RegisterUseCaseRequest(
|
||||
@@ -115,10 +118,11 @@ async def register(
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
session_store=Depends(get_auth_session_store),
|
||||
):
|
||||
use_case = LoginUseCase(
|
||||
user_repository=user_repository,
|
||||
session_store=_NoopSessionStore(),
|
||||
session_store=session_store,
|
||||
jwt_secret_key=settings.JWT_SECRET_KEY,
|
||||
)
|
||||
response, error = use_case.execute(LoginUseCaseRequest(email=request.email, password=request.password))
|
||||
@@ -164,11 +168,12 @@ async def verify_email_post(
|
||||
async def forgot_password(
|
||||
request: PasswordResetRequestModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
email_service=Depends(get_auth_email_service),
|
||||
):
|
||||
success, error = RequestPasswordResetUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=_NoopEmailService(),
|
||||
email_service=email_service,
|
||||
).execute(PasswordResetUseCaseRequest(email=request.email))
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "密码重置请求失败")
|
||||
@@ -205,17 +210,12 @@ async def get_current_user_info(
|
||||
)
|
||||
|
||||
|
||||
class _NoopSessionStore:
|
||||
def save_session(self, **kwargs):
|
||||
return None
|
||||
class _NoopSessionStore(NoopSessionStore):
|
||||
pass
|
||||
|
||||
|
||||
class _NoopEmailService:
|
||||
def send_verification_email(self, **kwargs):
|
||||
return False, "Email delivery is disabled for compatibility auth routes"
|
||||
|
||||
def send_password_reset_email(self, **kwargs):
|
||||
return False, "Email delivery is disabled for compatibility auth routes"
|
||||
class _NoopEmailService(NoopEmailService):
|
||||
pass
|
||||
|
||||
|
||||
def _translate_auth_error(error: str | None) -> str:
|
||||
|
||||
@@ -24,9 +24,19 @@ class Settings(BaseSettings):
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
REDIS_MAX_CONNECTIONS: int = 50
|
||||
ENABLE_REDIS_SESSIONS: bool = False
|
||||
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
|
||||
ENABLE_EMAIL_DELIVERY: bool = False
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_EMAIL: str = ""
|
||||
SMTP_FROM_NAME: str = "小虾 SaaS"
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import redis
|
||||
from app.config import settings
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore, SessionStore
|
||||
from packages.adapters.smtp import EmailConfig, NoopEmailService, get_email_service
|
||||
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
|
||||
SQLAlchemyAssetLibraryRepository,
|
||||
)
|
||||
@@ -106,3 +109,26 @@ def get_workspace_invitation_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceInvitationRepository:
|
||||
return SQLAlchemyWorkspaceInvitationRepository(session)
|
||||
|
||||
|
||||
def get_auth_session_store():
|
||||
if not settings.ENABLE_REDIS_SESSIONS:
|
||||
return NoopSessionStore()
|
||||
return SessionStore(redis_client=redis.from_url(settings.REDIS_URL, decode_responses=True))
|
||||
|
||||
|
||||
def get_auth_email_service():
|
||||
if not settings.ENABLE_EMAIL_DELIVERY:
|
||||
return NoopEmailService()
|
||||
return get_email_service(
|
||||
EmailConfig(
|
||||
smtp_host=settings.SMTP_HOST,
|
||||
smtp_port=settings.SMTP_PORT,
|
||||
smtp_user=settings.SMTP_USER,
|
||||
smtp_password=settings.SMTP_PASSWORD,
|
||||
from_email=settings.SMTP_FROM_EMAIL,
|
||||
from_name=settings.SMTP_FROM_NAME,
|
||||
use_tls=settings.SMTP_USE_TLS,
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
+14
-1
@@ -61,7 +61,20 @@ infra/docker/deploy-staging.sh
|
||||
- 已存在业务表但缺少 `alembic_version` 时执行 `alembic stamp head && alembic upgrade head`
|
||||
- 空库时执行 `alembic upgrade head`
|
||||
|
||||
staging 已在 2026-06-21 验证:`alembic_version = 001`,健康检查通过。
|
||||
staging 已在 2026-06-21 验证:`alembic_version = 002`,健康检查通过。
|
||||
|
||||
---
|
||||
|
||||
## 外部服务配置
|
||||
|
||||
认证邮件和 session 存储为配置驱动:
|
||||
|
||||
- `ENABLE_EMAIL_DELIVERY=false` 时使用 no-op 邮件服务,不对外发送邮件。
|
||||
- `ENABLE_EMAIL_DELIVERY=true` 时必须配置 `SMTP_HOST`、`SMTP_PORT`、`SMTP_USER`、`SMTP_PASSWORD`、`SMTP_FROM_EMAIL`、`SMTP_FROM_NAME`、`SMTP_USE_TLS`。
|
||||
- `ENABLE_REDIS_SESSIONS=false` 时登录不会写 Redis session,JWT 仍可用于当前认证链路。
|
||||
- `ENABLE_REDIS_SESSIONS=true` 时通过 `REDIS_URL` 创建 Redis session store。
|
||||
|
||||
Staging 当前可以保持 no-op;Production 开启前必须先验证 SMTP/Redis 连接和密钥。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -314,6 +314,17 @@ python -m pytest tests/unit/test_login_use_case.py tests/unit/test_register_user
|
||||
- 邮件发送在当前 compatibility route 中仍使用 no-op email service,接入真实 SMTP adapter 前不会对外发信。
|
||||
- 已修复 password reset token 过期判断中的 naive/aware datetime 比较风险。
|
||||
|
||||
### P1:外部服务 adapter 化收尾(已配置驱动)
|
||||
|
||||
已完成:
|
||||
- 认证 route 不再持有私有 no-op email/session 类,改为从 `apps/api/app/dependencies.py` 获取配置驱动 adapter。
|
||||
- `ENABLE_EMAIL_DELIVERY=false` 时使用 no-op email service,`true` 时按 SMTP 配置创建 `EmailService`。
|
||||
- `ENABLE_REDIS_SESSIONS=false` 时使用 no-op session store,`true` 时按 `REDIS_URL` 创建 Redis session store。
|
||||
- SMTP/Redis no-op 实现放在 adapter 层,use case 仍通过构造注入使用。
|
||||
|
||||
剩余建议:
|
||||
- Production 开启前需要真实 SMTP/Redis 连接验证和密钥审计。
|
||||
|
||||
### P1:Domain 层存在外部基础设施依赖
|
||||
|
||||
涉及:
|
||||
@@ -353,7 +364,7 @@ python -m pytest tests/unit/test_login_use_case.py tests/unit/test_register_user
|
||||
|
||||
## 五、下一步建议修复顺序
|
||||
|
||||
1. 外部服务 adapter 化收尾:接入真实 Redis session / SMTP email 生产配置。
|
||||
2. 生产 Alembic 准备:备份、回滚、首次生产 stamp/upgrade 演练。
|
||||
1. 生产 Alembic 准备:备份、回滚、首次生产 stamp/upgrade 演练。
|
||||
2. Production 外部服务上线:验证 SMTP/Redis 连接、密钥和告警。
|
||||
3. 继续清理旧术语:MinIO/OSS 混合文档、Postgres psycopg 历史文档。
|
||||
4. 全量测试和 CI:后端 unit/integration + 前端 type-check/build + staging smoke。
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from packages.adapters.redis.session_store import (
|
||||
NoopSessionStore,
|
||||
RedisConfig,
|
||||
SessionStore,
|
||||
get_session_store,
|
||||
)
|
||||
|
||||
__all__ = ["RedisConfig", "SessionStore", "get_session_store"]
|
||||
__all__ = ["NoopSessionStore", "RedisConfig", "SessionStore", "get_session_store"]
|
||||
|
||||
@@ -21,6 +21,32 @@ class RedisConfig:
|
||||
DECODE_RESPONSES: bool = True
|
||||
|
||||
|
||||
class NoopSessionStore:
|
||||
def save_session(self, **kwargs) -> bool:
|
||||
return False
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
def get_refresh_token(self, session_id: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
def update_last_active(self, session_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def get_user_sessions(self, user_id: str) -> list[dict]:
|
||||
return []
|
||||
|
||||
def delete_all_user_sessions(self, user_id: str) -> int:
|
||||
return 0
|
||||
|
||||
def session_exists(self, session_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Session 存储服务"""
|
||||
|
||||
@@ -284,8 +310,10 @@ class SessionStore:
|
||||
_session_store = None
|
||||
|
||||
|
||||
def get_session_store() -> SessionStore:
|
||||
def get_session_store(enabled: bool = True):
|
||||
global _session_store
|
||||
if not enabled:
|
||||
return NoopSessionStore()
|
||||
if _session_store is None:
|
||||
_session_store = SessionStore()
|
||||
return _session_store
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from packages.adapters.smtp.email_service import (
|
||||
EmailConfig,
|
||||
EmailService,
|
||||
NoopEmailService,
|
||||
get_email_service,
|
||||
)
|
||||
|
||||
__all__ = ["EmailConfig", "EmailService", "get_email_service"]
|
||||
__all__ = ["EmailConfig", "EmailService", "NoopEmailService", "get_email_service"]
|
||||
|
||||
@@ -10,6 +10,17 @@ from email.mime.text import MIMEText
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class NoopEmailService:
|
||||
def send_verification_email(self, **kwargs):
|
||||
return False, "Email delivery is disabled"
|
||||
|
||||
def send_password_reset_email(self, **kwargs):
|
||||
return False, "Email delivery is disabled"
|
||||
|
||||
def send_workspace_invitation_email(self, **kwargs):
|
||||
return False, "Email delivery is disabled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailConfig:
|
||||
"""邮件配置"""
|
||||
@@ -326,8 +337,10 @@ class EmailService:
|
||||
_email_service = None
|
||||
|
||||
|
||||
def get_email_service() -> EmailService:
|
||||
def get_email_service(config: Optional[EmailConfig] = None, enabled: bool = True):
|
||||
global _email_service
|
||||
if _email_service is None:
|
||||
_email_service = EmailService()
|
||||
if not enabled:
|
||||
return NoopEmailService()
|
||||
if _email_service is None or config is not None:
|
||||
_email_service = EmailService(config)
|
||||
return _email_service
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.api.routes.auth import (
|
||||
from app.auth import get_current_user
|
||||
from app.config import settings
|
||||
|
||||
from packages.adapters.smtp import NoopEmailService
|
||||
from packages.application.auth.login_use_case import LoginRequest, LoginUseCase
|
||||
from packages.application.auth.register_user_use_case import RegisterUserRequest, RegisterUserUseCase
|
||||
from packages.domain.auth import password_hasher
|
||||
@@ -224,7 +225,9 @@ def test_password_reset_routes_use_use_cases():
|
||||
)
|
||||
repo.save(user)
|
||||
|
||||
forgot_response = asyncio.run(forgot_password(PasswordResetRequestModel(email="user@example.com"), repo))
|
||||
forgot_response = asyncio.run(
|
||||
forgot_password(PasswordResetRequestModel(email="user@example.com"), repo, NoopEmailService())
|
||||
)
|
||||
assert forgot_response.message == "如果账户存在,密码重置邮件已发送"
|
||||
assert user.password_reset_token
|
||||
|
||||
|
||||
Reference in New Issue
Block a user