diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 4b1ab9169..261c99c8c 100644 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -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: diff --git a/apps/api/app/config.py b/apps/api/app/config.py index f8fd6889b..cd058b2e4 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -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" diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index 949d8aad3..0ed7bfb36 100644 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -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, + ) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index df3a3cc85..a7f89ea07 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 连接和密钥。 --- diff --git a/docs/全面代码审计报告-2026-06-21.md b/docs/全面代码审计报告-2026-06-21.md index b6a62cbe7..eefa5d1fc 100644 --- a/docs/全面代码审计报告-2026-06-21.md +++ b/docs/全面代码审计报告-2026-06-21.md @@ -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。 diff --git a/packages/adapters/redis/__init__.py b/packages/adapters/redis/__init__.py index c7e4ebb75..ff0c2ac01 100644 --- a/packages/adapters/redis/__init__.py +++ b/packages/adapters/redis/__init__.py @@ -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"] diff --git a/packages/adapters/redis/session_store.py b/packages/adapters/redis/session_store.py index c09ea413b..1378ca8c8 100644 --- a/packages/adapters/redis/session_store.py +++ b/packages/adapters/redis/session_store.py @@ -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 diff --git a/packages/adapters/smtp/__init__.py b/packages/adapters/smtp/__init__.py index 00dca6fca..3fb21d2ba 100644 --- a/packages/adapters/smtp/__init__.py +++ b/packages/adapters/smtp/__init__.py @@ -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"] diff --git a/packages/adapters/smtp/email_service.py b/packages/adapters/smtp/email_service.py index 835c6bbcd..1726c94ff 100644 --- a/packages/adapters/smtp/email_service.py +++ b/packages/adapters/smtp/email_service.py @@ -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 diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 97c7ad01d..071e2aae5 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -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