diff --git a/api/.importlinter b/api/.importlinter index ff8d7d6fe5d..1939f41a963 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -579,6 +579,35 @@ forbidden_modules = sqlalchemy werkzeug +[importlinter:contract:web-authentication-service-boundary] +name = Web authentication application service is framework and persistence neutral +type = forbidden +source_modules = + services.web_authentication_service + services.entities.authentication_entities +forbidden_modules = + configs + controllers + extensions + flask + models + repositories + sqlalchemy + werkzeug + +[importlinter:contract:web-authentication-adapters-persistence-boundary] +name = Web authentication adapters do not access persistence directly +type = forbidden +source_modules = + services.web_authentication_adapters +allow_indirect_imports = True +forbidden_modules = + configs + extensions.ext_database + models + repositories + sqlalchemy + [importlinter:contract:web-app-runtime-query-service-boundary] name = Web app runtime query application service does not directly depend on configuration, feature implementation, transport, or ORM modules type = forbidden diff --git a/api/controllers/web/flask_admission.py b/api/controllers/web/flask_admission.py new file mode 100644 index 00000000000..e31b9c195fc --- /dev/null +++ b/api/controllers/web/flask_admission.py @@ -0,0 +1,64 @@ +"""Flask adapter for unauthenticated Web API admission.""" + +from collections.abc import Callable +from functools import wraps +from typing import Concatenate + +from flask import Response, abort, request + +from configs import dify_config +from controllers.console.wraps import ( + email_password_login_enabled, + enterprise_license_required, + setup_required, +) +from core.logging.context import get_request_id, get_trace_id +from enums import DeploymentEdition +from libs.helper import extract_remote_ip +from machinery.context import RequestContext + + +def web_anonymous_admission[T, **P, R]( + *, + editions: frozenset[DeploymentEdition] | None = None, + require_email_password_login: bool = False, + require_valid_enterprise_license: bool = False, +) -> Callable[ + [Callable[Concatenate[T, RequestContext, P], R]], + Callable[Concatenate[T, P], R | Response], +]: + """Apply Web bootstrap policy and inject a stable anonymous request context.""" + + def decorator( + view: Callable[Concatenate[T, RequestContext, P], R], + ) -> Callable[Concatenate[T, P], R | Response]: + @wraps(view) + def inject_request_context(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R: + context = RequestContext( + request_id=get_request_id(), + trace_id=get_trace_id() or request.headers.get("X-Trace-Id"), + account_id="", + active_workspace_id="", + remote_ip=extract_remote_ip(request), + ) + return view(self, context, *args, **kwargs) + + admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context + if require_valid_enterprise_license: + admitted = enterprise_license_required(admitted) + if require_email_password_login: + admitted = email_password_login_enabled(admitted) + admitted = setup_required(admitted) + + if editions is None: + return admitted + + @wraps(view) + def enforce_edition(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response: + if dify_config.DEPLOYMENT_EDITION not in editions: + abort(404) + return admitted(self, *args, **kwargs) + + return enforce_edition + + return decorator diff --git a/api/controllers/web/forgot_password.py b/api/controllers/web/forgot_password.py index 0be96287dec..d376e75f8b0 100644 --- a/api/controllers/web/forgot_password.py +++ b/api/controllers/web/forgot_password.py @@ -1,7 +1,3 @@ -import base64 -import secrets - -from flask import request from flask_restx import Resource from controllers.common.fields import SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse @@ -13,25 +9,32 @@ from controllers.console.auth.error import ( InvalidEmailError, InvalidTokenError, PasswordMismatchError, + PasswordResetRateLimitExceededError, ) from controllers.console.error import EmailSendIpLimitError -from controllers.console.wraps import ( - email_password_login_enabled, - model_validate, - only_edition_enterprise, - setup_required, -) +from controllers.console.wraps import model_validate from controllers.web import web_ns -from extensions.ext_database import db -from libs.helper import extract_remote_ip -from libs.password import hash_password -from models.account import Account -from services.account_service import AccountService +from controllers.web.flask_admission import web_anonymous_admission +from enums import DeploymentEdition +from extensions.ext_application_services import application_services +from machinery.context import RequestContext from services.entities.auth_entities import ( ForgotPasswordCheckPayload, ForgotPasswordResetPayload, ForgotPasswordSendPayload, ) +from services.web_authentication_service import ( + WebAuthenticationFailedError, + WebEmailDeliveryRateLimitError, + WebEmailSendIPLimitedError, + WebInvalidCodeError, + WebInvalidEmailError, + WebInvalidTokenError, + WebPasswordMismatchError, + WebPasswordResetVerificationLimitedError, +) + +_ENTERPRISE_ONLY = frozenset({DeploymentEdition.ENTERPRISE}) register_schema_models(web_ns, ForgotPasswordSendPayload, ForgotPasswordCheckPayload, ForgotPasswordResetPayload) register_response_schema_models( @@ -45,9 +48,7 @@ register_response_schema_models( @web_ns.route("/forgot-password") class ForgotPasswordSendEmailApi(Resource): @web_ns.expect(web_ns.models[ForgotPasswordSendPayload.__name__]) - @only_edition_enterprise - @setup_required - @email_password_login_enabled + @web_anonymous_admission(editions=_ENTERPRISE_ONLY, require_email_password_login=True) @web_ns.doc("send_forgot_password_email") @web_ns.doc(description="Send password reset email") @web_ns.doc( @@ -60,34 +61,27 @@ class ForgotPasswordSendEmailApi(Resource): ) @web_ns.response(200, "Password reset email sent successfully", web_ns.models[SimpleResultDataResponse.__name__]) @model_validate(ForgotPasswordSendPayload) - def post(self, payload: ForgotPasswordSendPayload): - request_email = payload.email - normalized_email = request_email.lower() + def post(self, payload: ForgotPasswordSendPayload, request_context: RequestContext): + try: + token = application_services().web_authentication.send_reset_password_email( + request_context, + email=payload.email, + language=payload.language, + ) + except WebEmailSendIPLimitedError as error: + raise EmailSendIpLimitError() from error + except WebAuthenticationFailedError as error: + raise AuthenticationFailedError() from error + except WebEmailDeliveryRateLimitError as error: + raise PasswordResetRateLimitExceededError(error.retry_after_minutes) from error - ip_address = extract_remote_ip(request) - if AccountService.is_email_send_ip_limit(ip_address): - raise EmailSendIpLimitError() - - if payload.language == "zh-Hans": - language = "zh-Hans" - else: - language = "en-US" - - account = AccountService.get_account_by_email_with_case_fallback(request_email, session=db.session()) - if account is None: - raise AuthenticationFailedError() - else: - token = AccountService.send_reset_password_email(account=account, email=normalized_email, language=language) - - return {"result": "success", "data": token} + return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json") @web_ns.route("/forgot-password/validity") class ForgotPasswordCheckApi(Resource): @web_ns.expect(web_ns.models[ForgotPasswordCheckPayload.__name__]) - @only_edition_enterprise - @setup_required - @email_password_login_enabled + @web_anonymous_admission(editions=_ENTERPRISE_ONLY, require_email_password_login=True) @web_ns.doc("check_forgot_password_token") @web_ns.doc(description="Verify password reset token validity") @web_ns.doc( @@ -95,47 +89,33 @@ class ForgotPasswordCheckApi(Resource): ) @web_ns.response(200, "Token is valid", web_ns.models[VerificationTokenResponse.__name__]) @model_validate(ForgotPasswordCheckPayload) - def post(self, payload: ForgotPasswordCheckPayload): - user_email = payload.email.lower() + def post(self, payload: ForgotPasswordCheckPayload, _request_context: RequestContext): + try: + new_token = application_services().web_authentication.verify_reset_password_code( + email=payload.email, + code=payload.code, + token=payload.token, + ) + except WebPasswordResetVerificationLimitedError as error: + raise EmailPasswordResetLimitError() from error + except WebInvalidTokenError as error: + raise InvalidTokenError() from error + except WebInvalidEmailError as error: + raise InvalidEmailError() from error + except WebInvalidCodeError as error: + raise EmailCodeError() from error - is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(user_email) - if is_forgot_password_error_rate_limit: - raise EmailPasswordResetLimitError() - - token_data = AccountService.get_reset_password_data(payload.token) - if token_data is None: - raise InvalidTokenError() - - token_email = token_data.get("email") - if not isinstance(token_email, str): - raise InvalidEmailError() - normalized_token_email = token_email.lower() - - if user_email != normalized_token_email: - raise InvalidEmailError() - - if payload.code != token_data.get("code"): - AccountService.add_forgot_password_error_rate_limit(user_email) - raise EmailCodeError() - - # Verified, revoke the first token - AccountService.revoke_reset_password_token(payload.token) - - # Refresh token data by generating a new token - _, new_token = AccountService.generate_reset_password_token( - token_email, code=payload.code, additional_data={"phase": "reset"} - ) - - AccountService.reset_forgot_password_error_rate_limit(user_email) - return {"is_valid": True, "email": normalized_token_email, "token": new_token} + return VerificationTokenResponse( + is_valid=True, + email=payload.email.lower(), + token=new_token, + ).model_dump(mode="json") @web_ns.route("/forgot-password/resets") class ForgotPasswordResetApi(Resource): @web_ns.expect(web_ns.models[ForgotPasswordResetPayload.__name__]) - @only_edition_enterprise - @setup_required - @email_password_login_enabled + @web_anonymous_admission(editions=_ENTERPRISE_ONLY, require_email_password_login=True) @web_ns.doc("reset_password") @web_ns.doc(description="Reset user password with verification token") @web_ns.doc( @@ -148,40 +128,18 @@ class ForgotPasswordResetApi(Resource): ) @web_ns.response(200, "Password reset successfully", web_ns.models[SimpleResultResponse.__name__]) @model_validate(ForgotPasswordResetPayload) - def post(self, payload: ForgotPasswordResetPayload): - # Validate passwords match - if payload.new_password != payload.password_confirm: - raise PasswordMismatchError() + def post(self, payload: ForgotPasswordResetPayload, _request_context: RequestContext): + try: + application_services().web_authentication.reset_password( + token=payload.token, + new_password=payload.new_password, + password_confirmation=payload.password_confirm, + ) + except WebPasswordMismatchError as error: + raise PasswordMismatchError() from error + except WebInvalidTokenError as error: + raise InvalidTokenError() from error + except WebAuthenticationFailedError as error: + raise AuthenticationFailedError() from error - # Validate token and get reset data - reset_data = AccountService.get_reset_password_data(payload.token) - if not reset_data: - raise InvalidTokenError() - # Must use token in reset phase - if reset_data.get("phase", "") != "reset": - raise InvalidTokenError() - - # Revoke token to prevent reuse - AccountService.revoke_reset_password_token(payload.token) - - # Generate secure salt and hash password - salt = secrets.token_bytes(16) - password_hashed = hash_password(payload.new_password, salt) - - email = reset_data.get("email", "") - - account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session()) - - if account: - account = db.session.merge(account) - self._update_existing_account(account, password_hashed, salt) - db.session.commit() - else: - raise AuthenticationFailedError() - - return {"result": "success"} - - def _update_existing_account(self, account: Account, password_hashed, salt): - # Update existing account credentials - account.password = base64.b64encode(password_hashed).decode() - account.password_salt = base64.b64encode(salt).decode() + return SimpleResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/web/login.py b/api/controllers/web/login.py index fad4fc0f513..54d11abdf33 100644 --- a/api/controllers/web/login.py +++ b/api/controllers/web/login.py @@ -1,4 +1,3 @@ -import logging from http import HTTPStatus from flask import make_response, request @@ -7,8 +6,6 @@ from jwt import InvalidTokenError from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import Unauthorized -import services -from configs import dify_config from controllers.common.fields import ( AccessTokenData, AccessTokenResultResponse, @@ -27,27 +24,31 @@ from controllers.console.wraps import ( decrypt_code_field, decrypt_password_field, model_validate, - only_edition_enterprise, - setup_required, ) from controllers.web import web_ns -from controllers.web.wraps import decode_jwt_token, resolve_web_app_id +from controllers.web.error import WebAppAccessServiceUnavailableError, WebAppNotFoundError +from controllers.web.flask_admission import web_anonymous_admission from enums import DeploymentEdition from extensions.ext_application_services import application_services -from extensions.ext_database import db -from libs.helper import EmailStr, extract_remote_ip -from libs.passport import PassportService +from libs.helper import EmailStr from libs.password import valid_password from libs.token import ( clear_webapp_access_token_from_cookie, extract_webapp_access_token, + extract_webapp_passport, ) -from services.account_service import AccountService -from services.entities.auth_audit_entities import LoginFailureReason +from machinery.context import RequestContext from services.entities.auth_entities import LoginPayloadBase -from services.webapp_auth_service import WebAppAuthService +from services.web_authentication_service import ( + WebAccountBannedError, + WebAuthenticationFailedError, + WebInvalidCodeError, + WebInvalidEmailError, + WebInvalidTokenError, +) +from services.webapp_access_query_service import WebAppAccessAppNotFoundError, WebAppAccessUnavailableError -logger = logging.getLogger(__name__) +_ENTERPRISE_ONLY = frozenset({DeploymentEdition.ENTERPRISE}) class LoginPayload(LoginPayloadBase): @@ -88,8 +89,7 @@ class LoginApi(Resource): """Resource for web app email/password login.""" @web_ns.expect(web_ns.models[LoginPayload.__name__]) - @setup_required - @only_edition_enterprise + @web_anonymous_admission(editions=_ENTERPRISE_ONLY) @web_ns.doc("web_app_login") @web_ns.doc(description="Authenticate user for web application access") @web_ns.doc( @@ -104,23 +104,19 @@ class LoginApi(Resource): @web_ns.response(200, "Authentication successful", web_ns.models[AccessTokenResultResponse.__name__]) @decrypt_password_field @model_validate(LoginPayload) - def post(self, payload: LoginPayload): + def post(self, payload: LoginPayload, request_context: RequestContext): """Authenticate user and login.""" - normalized_email = payload.email.lower() - try: - account = WebAppAuthService.authenticate(payload.email, payload.password, db.session()) - except services.errors.account.AccountLoginError: - _log_web_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_BANNED) - raise AccountBannedError() - except services.errors.account.AccountPasswordError: - _log_web_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS) - raise AuthenticationFailedError() - except services.errors.account.AccountNotFoundError: - _log_web_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_NOT_FOUND) - raise AuthenticationFailedError() + token = application_services().web_authentication.login_with_password( + request_context, + email=payload.email, + password=payload.password, + ) + except WebAccountBannedError as error: + raise AccountBannedError() from error + except WebAuthenticationFailedError as error: + raise AuthenticationFailedError() from error - token = WebAppAuthService.login(account=account) # set_access_token_to_cookie(request, response, token, samesite="None", httponly=False) return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump( mode="json" @@ -131,7 +127,7 @@ class LoginApi(Resource): # TODO: remove in the future. frontend should redirect to login page by catching 401 status @web_ns.route("/login/status") class LoginStatusApi(Resource): - @setup_required + @web_anonymous_admission() @web_ns.doc("web_app_login_status") @web_ns.doc(description="Check login status") @web_ns.doc(params=query_params_from_model(LoginStatusQuery)) @@ -143,40 +139,27 @@ class LoginStatusApi(Resource): ) @web_ns.response(HTTPStatus.OK, "Login status", web_ns.models[LoginStatusResponse.__name__]) @model_validate(LoginStatusQuery) - def get(self, query: LoginStatusQuery): - app_code = query.app_code - user_id = query.user_id - token = extract_webapp_access_token(request) - if not app_code: - return LoginStatusResponse(logged_in=bool(token), app_logged_in=False).model_dump(mode="json") - app_id = resolve_web_app_id(app_code) - is_public = ( - dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE - or not application_services().webapp_access.requires_permission_check(app_id) - ) - user_logged_in = False - - if is_public: - user_logged_in = True - else: - try: - PassportService().verify(token=token) - user_logged_in = True - except Exception: - user_logged_in = False - + def get(self, query: LoginStatusQuery, _request_context: RequestContext): try: - _ = decode_jwt_token(app_code=app_code, user_id=user_id) - app_logged_in = True - except Exception: - app_logged_in = False - - return LoginStatusResponse(logged_in=user_logged_in, app_logged_in=app_logged_in).model_dump(mode="json") + status = application_services().web_authentication.get_login_status( + app_code=query.app_code, + user_id=query.user_id, + access_token=extract_webapp_access_token(request), + app_session_token=extract_webapp_passport(query.app_code, request) if query.app_code else None, + ) + except WebAppAccessAppNotFoundError as error: + raise WebAppNotFoundError() from error + except WebAppAccessUnavailableError as error: + raise WebAppAccessServiceUnavailableError() from error + return LoginStatusResponse( + logged_in=status.logged_in, + app_logged_in=status.app_logged_in, + ).model_dump(mode="json") @web_ns.route("/logout") class LogoutApi(Resource): - @setup_required + @web_anonymous_admission() @web_ns.doc("web_app_logout") @web_ns.doc(description="Logout user from web application") @web_ns.doc( @@ -185,7 +168,7 @@ class LogoutApi(Resource): } ) @web_ns.response(200, "Logout successful", web_ns.models[SimpleResultResponse.__name__]) - def post(self): + def post(self, _request_context: RequestContext): # response-contract:ignore hand-crafted response response = make_response(SimpleResultResponse(result="success").model_dump(mode="json")) # enterprise SSO sets same site to None in https deployment @@ -196,8 +179,7 @@ class LogoutApi(Resource): @web_ns.route("/email-code-login") class EmailCodeLoginSendEmailApi(Resource): - @setup_required - @only_edition_enterprise + @web_anonymous_admission(editions=_ENTERPRISE_ONLY) @web_ns.doc("send_email_code_login") @web_ns.doc(description="Send email verification code for login") @web_ns.expect(web_ns.models[EmailCodeLoginSendPayload.__name__]) @@ -210,23 +192,22 @@ class EmailCodeLoginSendEmailApi(Resource): ) @web_ns.response(200, "Email code sent successfully", web_ns.models[SimpleResultDataResponse.__name__]) @model_validate(EmailCodeLoginSendPayload) - def post(self, payload: EmailCodeLoginSendPayload): - if payload.language == "zh-Hans": - language = "zh-Hans" - else: - language = "en-US" - - account = WebAppAuthService.get_user_through_email(payload.email, db.session()) - if account is None: - raise AuthenticationFailedError() - token = WebAppAuthService.send_email_code_login_email(account=account, language=language) + def post(self, payload: EmailCodeLoginSendPayload, _request_context: RequestContext): + try: + token = application_services().web_authentication.send_email_login_code( + email=payload.email, + language=payload.language, + ) + except WebAccountBannedError as error: + raise Unauthorized("Account is banned.") from error + except WebAuthenticationFailedError as error: + raise AuthenticationFailedError() from error return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json") @web_ns.route("/email-code-login/validity") class EmailCodeLoginApi(Resource): - @setup_required - @only_edition_enterprise + @web_anonymous_admission(editions=_ENTERPRISE_ONLY) @web_ns.doc("verify_email_code_login") @web_ns.doc(description="Verify email code and complete login") @web_ns.expect(web_ns.models[EmailCodeLoginVerifyPayload.__name__]) @@ -245,49 +226,26 @@ class EmailCodeLoginApi(Resource): ) @decrypt_code_field @model_validate(EmailCodeLoginVerifyPayload) - def post(self, payload: EmailCodeLoginVerifyPayload): - user_email = payload.email.lower() - - token_data = WebAppAuthService.get_email_code_login_data(payload.token) - if token_data is None: - _log_web_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN) - raise InvalidTokenError() - - token_email = token_data.get("email") - if not isinstance(token_email, str): - _log_web_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH) - raise InvalidEmailError() - normalized_token_email = token_email.lower() - if normalized_token_email != user_email: - _log_web_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH) - raise InvalidEmailError() - - if token_data["code"] != payload.code: - _log_web_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE) - raise EmailCodeError() - - WebAppAuthService.revoke_email_code_login_token(payload.token) + def post(self, payload: EmailCodeLoginVerifyPayload, request_context: RequestContext): try: - account = WebAppAuthService.get_user_through_email(token_email, db.session()) - except Unauthorized as exc: - _log_web_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_BANNED) - raise AccountBannedError() from exc - if not account: - _log_web_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_NOT_FOUND) - raise AuthenticationFailedError() + token = application_services().web_authentication.login_with_email_code( + request_context, + email=payload.email, + code=payload.code, + token=payload.token, + ) + except WebInvalidTokenError as error: + raise InvalidTokenError() from error + except WebInvalidEmailError as error: + raise InvalidEmailError() from error + except WebInvalidCodeError as error: + raise EmailCodeError() from error + except WebAccountBannedError as error: + raise AccountBannedError() from error + except WebAuthenticationFailedError as error: + raise AuthenticationFailedError() from error - token = WebAppAuthService.login(account=account) - AccountService.reset_login_error_rate_limit(user_email) # set_access_token_to_cookie(request, response, token, samesite="None", httponly=False) return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump( mode="json" ) - - -def _log_web_login_failure(*, email: str, reason: LoginFailureReason) -> None: - logger.warning( - "Web login failed: email=%s reason=%s ip_address=%s", - email, - reason, - extract_remote_ip(request), - ) diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 0aa35bf44b1..6e43867eb65 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -155,6 +155,7 @@ from services.account_oauth_service import AccountOAuthService, OAuthProviderGat from services.account_password_hasher import DefaultAccountPasswordHasher from services.account_password_service import AccountPasswordService from services.account_profile_service import AccountProfileService +from services.account_service import AccountService from services.agent.roster_package_exporter import RosterAgentPackageExporter from services.app.advanced_prompt_template_service import AdvancedPromptTemplateService from services.app.api_key_service import AppApiKeyService @@ -235,15 +236,20 @@ from services.tag_application_service import TagApplicationService from services.tool_file_download_service import ToolFileDownloadService from services.upload_file_delivery_service import UploadFileDeliveryService from services.web_app_runtime_query_service import WebAppRuntimeQueryService +from services.web_authentication_adapters import ( + AccountServiceWebAuthenticationSecurityGateway, + LoggingWebAuthenticationAuditGateway, + PassportWebAppSessionGateway, + TokenManagerWebAuthenticationGateway, +) +from services.web_authentication_service import WebAuthenticationService from services.web_passport_gateways import ( DeploymentWebPassportAuthGateway, PassportTokenGateway, ) from services.web_passport_service import WebPassportService -from services.webapp_access_query_service import ( - WebAppAccessQueryService, - WebAppAccessUnavailableError, -) +from services.webapp_access_adapters import EnterpriseWebAppAccessPolicyGateway +from services.webapp_access_query_service import WebAppAccessQueryService, WebAppAccessUnavailableError from services.workflow_app_log_query_service import WorkflowAppLogQueryService from services.workflow_run_service import WorkflowRunService from services.workflow_statistic_query_service import WorkflowStatisticQueryService @@ -259,32 +265,14 @@ _EXTENSION_KEY = "application_services" # TODO: Normalize EnterpriseService.WebAppAuth result/error contracts in the SDK, -# migrate its callers, then inject its methods directly and remove these adapters. +# migrate its callers, then inject its batch methods directly and remove these wrappers. # Define SDK errors for timeouts, transport failures, upstream status and invalid -# responses before adding finer HTTP mappings; these adapters report unavailability. +# responses before adding finer HTTP mappings; these wrappers report unavailability. # Validate required fields and real booleans there, replacing legacy permission # truthiness conversion. Missing fields currently become False, {} or a default mode. # Replace response-shape ValueError/KeyError/AttributeError with typed SDK errors; # ordinary ValueError can still reach the global 400 invalid_param handler. The # lost field information cannot be recovered by translating exceptions here. -def _get_enterprise_webapp_access_mode(app_id: str) -> WebAppAccessMode: - try: - settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id) - except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError, ValidationError) as e: - raise WebAppAccessUnavailableError from e - try: - return WebAppAccessMode(settings.access_mode) - except ValueError as e: - raise WebAppAccessUnavailableError from e - - -def _is_enterprise_webapp_user_allowed(user_id: str, app_id: str) -> bool: - try: - return EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_id) - except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError) as e: - raise WebAppAccessUnavailableError from e - - def _batch_get_enterprise_webapp_access_modes(*, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]: try: settings = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(list(app_ids)) @@ -358,6 +346,7 @@ class ApplicationServices: knowledge: KnowledgeServices app_scoped_end_users: AppScopedEndUserServices webapp_access: WebAppAccessQueryService + web_authentication: WebAuthenticationService web_app_runtime: WebAppRuntimeQueryService explore_banner_queries: ExploreBannerQueryService schema_definitions: SchemaDefinitionService @@ -530,11 +519,11 @@ def build_application_services( ), ) webapp_auth_enabled = SystemFeatureService.is_webapp_auth_enabled(deployment_edition=deployment_edition) + webapp_access_repository = WebAppAccessQueryRepository(session_factory=database_client) webapp_access = WebAppAccessQueryService( - access=WebAppAccessQueryRepository(session_factory=database_client), + access=webapp_access_repository, + policy=EnterpriseWebAppAccessPolicyGateway(webapp_auth=EnterpriseService.WebAppAuth), webapp_auth_enabled=webapp_auth_enabled, - access_mode_for_app=_get_enterprise_webapp_access_mode, - is_user_allowed_for_app=_is_enterprise_webapp_user_allowed, get_access_modes=_batch_get_enterprise_webapp_access_modes, get_user_permissions=_batch_get_enterprise_webapp_user_permissions, ) @@ -598,6 +587,10 @@ def build_application_services( file_service = FileService(session_factory=database_client) remote_file_service = RemoteFileService(files=file_service) passwords = DefaultAccountPasswordHasher() + web_authentication_tokens = TokenManagerWebAuthenticationGateway( + reset_password_rate_limiter=AccountService.reset_password_rate_limiter, + access_token_expire_minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES, + ) invitation_tokens = RedisInvitationTokenStore(redis=redis) activation_accounts = SQLAlchemyAccountActivationRepository(session_factory=database_client) account_provisioning = SQLAlchemyConsoleAuthProvisioningGateway(session_factory=database_client) @@ -807,6 +800,21 @@ def build_application_services( queries=AppScopedEndUserQueryService(end_users=app_scoped_end_user_repository), ), webapp_access=webapp_access, + web_authentication=WebAuthenticationService( + accounts=accounts, + passwords=passwords, + tokens=web_authentication_tokens, + security=AccountServiceWebAuthenticationSecurityGateway( + account_service=AccountService, + ), + app_access=webapp_access, + app_sessions=PassportWebAppSessionGateway( + sessions=webapp_access_repository, + app_access=webapp_access, + ), + audit=LoggingWebAuthenticationAuditGateway(logger=logging.getLogger("controllers.web.login")), + private_app_access_enabled=deployment_edition == DeploymentEdition.ENTERPRISE, + ), installed_apps=build_installed_app_services( database_client=database_client, webapp_access=webapp_access, diff --git a/api/machinery/context.py b/api/machinery/context.py index d64b8882190..f87c0027d70 100644 --- a/api/machinery/context.py +++ b/api/machinery/context.py @@ -5,10 +5,16 @@ from typing import NamedTuple class RequestContext(NamedTuple): + """Framework-neutral request metadata and admitted identity. + + Anonymous admission uses empty ``account_id`` and ``active_workspace_id`` values. + """ + request_id: str trace_id: str | None account_id: str active_workspace_id: str + remote_ip: str | None = None @dataclass(frozen=True, slots=True, kw_only=True) diff --git a/api/repositories/account_repository.py b/api/repositories/account_repository.py index 46500d04638..9d758e3cc4b 100644 --- a/api/repositories/account_repository.py +++ b/api/repositories/account_repository.py @@ -11,6 +11,7 @@ from services.account_email import normalize_email from services.account_login_service import ConsoleAuthAccountRepository from services.account_ports import AccountRepository from services.entities.account_entities import ( + AccountAuthenticationSnapshot, AccountCredentials, AccountEmailResetResult, AccountEmailResetStatus, @@ -63,6 +64,28 @@ class SQLAlchemyAccountRepository(AccountRepository, ConsoleAuthAccountRepositor return None return AccountCredentials(password_hash=account.password, password_salt=account.password_salt) + @override + def find_for_authentication(self, email: str) -> AccountAuthenticationSnapshot | None: + """Find an exact email match, falling back to the oldest normalized match.""" + with self._session_factory() as session: + account = session.execute(select(Account).where(Account.email == email)).scalar_one_or_none() + if account is None: + account = session.execute( + select(Account) + .where(Account.normalized_email == normalize_email(email)) + .order_by(Account.created_at) + .limit(1) + ).scalar_one_or_none() + if account is None: + return None + return AccountAuthenticationSnapshot( + id=account.id, + email=account.email, + status=account.status.value, + password_hash=account.password, + password_salt=account.password_salt, + ) + @override def list_for_login(self, email: str) -> tuple[LoginAccountSnapshot, ...]: normalized_email = email.lower() diff --git a/api/repositories/webapp_access_query_repository.py b/api/repositories/webapp_access_query_repository.py index cb29ce2603e..a3d9180b24b 100644 --- a/api/repositories/webapp_access_query_repository.py +++ b/api/repositories/webapp_access_query_repository.py @@ -6,11 +6,13 @@ from sqlalchemy import select from sqlalchemy.exc import DBAPIError, TimeoutError from sqlalchemy.orm import Session, sessionmaker -from models.model import Site +from models.model import App, EndUser, Site +from services.entities.authentication_entities import WebAppSessionRecord +from services.web_authentication_service import WebAppSessionQuery from services.webapp_access_query_service import WebAppAccessQuery, WebAppAccessUnavailableError -class WebAppAccessQueryRepository(WebAppAccessQuery): +class WebAppAccessQueryRepository(WebAppAccessQuery, WebAppSessionQuery): def __init__(self, *, session_factory: sessionmaker[Session]) -> None: self._session_factory = session_factory @@ -22,3 +24,34 @@ class WebAppAccessQueryRepository(WebAppAccessQuery): return app_id if app_id is not None else None except (DBAPIError, TimeoutError) as e: raise WebAppAccessUnavailableError from e + + @override + def find_active_session( + self, + *, + app_id: str, + app_code: str, + end_user_id: str, + ) -> WebAppSessionRecord | None: + stmt = ( + select(EndUser.session_id) + .select_from(Site) + .join(App, App.id == Site.app_id) + .join(EndUser, EndUser.app_id == App.id) + .where( + Site.code == app_code, + App.id == app_id, + App.enable_site.is_(True), + EndUser.id == end_user_id, + EndUser.tenant_id == App.tenant_id, + ) + .limit(1) + ) + try: + with self._session_factory() as session: + session_id = session.scalar(stmt) + if session_id is None: + return None + return WebAppSessionRecord(end_user_session_id=session_id) + except (DBAPIError, TimeoutError) as e: + raise WebAppAccessUnavailableError from e diff --git a/api/services/account_ports.py b/api/services/account_ports.py index 6914dc3fdf6..b7530a64c42 100644 --- a/api/services/account_ports.py +++ b/api/services/account_ports.py @@ -10,6 +10,7 @@ from services.entities.account_access_entities import ( AccountWorkspaceSnapshot, ) from services.entities.account_entities import ( + AccountAuthenticationSnapshot, AccountCredentials, AccountDeletionChallenge, AccountEmailResetResult, @@ -35,6 +36,8 @@ class AccountRepository(Protocol): def get_credentials(self, account_id: str) -> AccountCredentials | None: ... + def find_for_authentication(self, email: str) -> AccountAuthenticationSnapshot | None: ... + def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None: ... def update_password(self, account_id: str, password: AccountPasswordDigest) -> AccountSnapshot | None: ... diff --git a/api/services/entities/account_entities.py b/api/services/entities/account_entities.py index cfc637b09e1..abd500a1bec 100644 --- a/api/services/entities/account_entities.py +++ b/api/services/entities/account_entities.py @@ -51,6 +51,15 @@ class AccountCredentials: password_salt: str | None +@dataclass(frozen=True, slots=True) +class AccountAuthenticationSnapshot: + id: str + email: str + status: str + password_hash: str | None + password_salt: str | None + + @dataclass(frozen=True, slots=True) class AccountPasswordDigest: password_hash: str diff --git a/api/services/entities/authentication_entities.py b/api/services/entities/authentication_entities.py new file mode 100644 index 00000000000..286eaaa5fa5 --- /dev/null +++ b/api/services/entities/authentication_entities.py @@ -0,0 +1,21 @@ +"""Framework-neutral contracts shared by authentication use cases.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class StoredAuthenticationToken: + email: str | None + code: str | None + phase: str | None = None + + +@dataclass(frozen=True, slots=True) +class WebAppSessionRecord: + end_user_session_id: str + + +@dataclass(frozen=True, slots=True) +class WebLoginStatus: + logged_in: bool + app_logged_in: bool diff --git a/api/services/web_authentication_adapters.py b/api/services/web_authentication_adapters.py new file mode 100644 index 00000000000..8cb8982acdb --- /dev/null +++ b/api/services/web_authentication_adapters.py @@ -0,0 +1,243 @@ +"""Infrastructure adapters for Web authentication application ports.""" + +import logging +import secrets +from datetime import UTC, datetime, timedelta +from typing import Any, override + +from libs.helper import RateLimiter, TokenManager +from libs.passport import PassportService +from services.account_service import AccountService +from services.enterprise.enterprise_service import EnterpriseService +from services.entities.auth_audit_entities import LoginFailureReason +from services.entities.authentication_entities import StoredAuthenticationToken +from services.web_authentication_service import ( + WebAppAccessGateway, + WebAppSessionGateway, + WebAppSessionQuery, + WebAuthenticationAuditGateway, + WebAuthenticationSecurityGateway, + WebAuthenticationTokenGateway, + WebEmailDeliveryRateLimitError, +) +from services.web_passport_gateways import resolve_web_app_auth_type +from services.web_passport_service import WebAppAuthType +from tasks.mail_email_code_login import send_email_code_login_mail_task +from tasks.mail_reset_password_task import send_reset_password_mail_task + + +class TokenManagerWebAuthenticationGateway(WebAuthenticationTokenGateway): + """Web authentication tokens backed by TokenManager and PassportService.""" + + def __init__( + self, + *, + reset_password_rate_limiter: RateLimiter, + access_token_expire_minutes: int, + ) -> None: + self._reset_password_rate_limiter = reset_password_rate_limiter + self._access_token_expire_minutes = access_token_expire_minutes + + @override + def issue_access_token(self, *, account_id: str, email: str) -> str: + expires_at = datetime.now(UTC) + timedelta(minutes=self._access_token_expire_minutes) + return PassportService().issue( + { + "sub": "Web API Passport", + "user_id": account_id, + "session_id": email, + "token_source": "webapp_login_token", + "auth_type": "internal", + "exp": int(expires_at.timestamp()), + } + ) + + @override + def verify_access_token(self, token: str | None) -> bool: + if not token: + return False + try: + PassportService().verify(token=token) + except Exception: + return False + return True + + @override + def send_email_login_code(self, *, account_id: str, email: str, language: str) -> str: + code = self._generate_code() + token = TokenManager.generate_token( + account_id=account_id, + email=email, + token_type="email_code_login", + additional_data={"code": code}, + ) + send_email_code_login_mail_task.delay(language=language, to=email, code=code) + return token + + @override + def get_email_login_token(self, token: str) -> StoredAuthenticationToken | None: + return self._read_token(token, "email_code_login") + + @override + def revoke_email_login_token(self, token: str) -> None: + TokenManager.revoke_token(token, "email_code_login") + + @override + def send_reset_password_code(self, *, account_id: str, email: str, language: str) -> str: + limiter = self._reset_password_rate_limiter + if limiter.is_rate_limited(email): + raise WebEmailDeliveryRateLimitError( + flow="reset_password", + retry_after_minutes=int(limiter.time_window / 60), + ) + code = self._generate_code() + token = TokenManager.generate_token( + account_id=account_id, + email=email, + token_type="reset_password", + additional_data={"code": code}, + ) + send_reset_password_mail_task.delay(language=language, to=email, code=code) + limiter.increment_rate_limit(email) + return token + + @override + def get_reset_password_token(self, token: str) -> StoredAuthenticationToken | None: + return self._read_token(token, "reset_password") + + @override + def replace_reset_password_token(self, *, email: str, code: str) -> str: + return TokenManager.generate_token( + email=email, + token_type="reset_password", + additional_data={"code": code, "phase": "reset"}, + ) + + @override + def revoke_reset_password_token(self, token: str) -> None: + TokenManager.revoke_token(token, "reset_password") + + @staticmethod + def _generate_code() -> str: + return "".join(str(secrets.randbelow(10)) for _ in range(6)) + + @staticmethod + def _read_token(token: str, token_type: str) -> StoredAuthenticationToken | None: + data = TokenManager.get_token_data(token, token_type) + if data is None: + return None + email = data.get("email") + code = data.get("code") + phase = data.get("phase") + return StoredAuthenticationToken( + email=email if isinstance(email, str) else None, + code=code if isinstance(code, str) else None, + phase=phase if isinstance(phase, str) else None, + ) + + +class AccountServiceWebAuthenticationSecurityGateway(WebAuthenticationSecurityGateway): + def __init__(self, *, account_service: type[AccountService]) -> None: + self._account_service = account_service + + @override + def is_email_send_ip_limited(self, ip_address: str) -> bool: + return bool(self._account_service.is_email_send_ip_limit(ip_address)) + + @override + def is_password_reset_verification_limited(self, email: str) -> bool: + return bool(self._account_service.is_forgot_password_error_rate_limit(email)) + + @override + def record_password_reset_verification_failure(self, email: str) -> None: + self._account_service.add_forgot_password_error_rate_limit(email) + + @override + def reset_password_reset_verification_failures(self, email: str) -> None: + self._account_service.reset_forgot_password_error_rate_limit(email) + + @override + def reset_login_failures(self, email: str) -> None: + self._account_service.reset_login_error_rate_limit(email) + + +class PassportWebAppSessionGateway(WebAppSessionGateway): + def __init__( + self, + *, + sessions: WebAppSessionQuery, + app_access: WebAppAccessGateway, + ) -> None: + self._sessions = sessions + self._app_access = app_access + + @override + def verify(self, *, token: str | None, app_code: str, user_id: str | None) -> bool: + if not token: + return False + try: + decoded = PassportService().verify(token) + token_app_code = decoded.get("app_code") + app_id = decoded.get("app_id") + end_user_id = decoded.get("end_user_id") + if not isinstance(token_app_code, str) or not isinstance(app_id, str) or not isinstance(end_user_id, str): + return False + if token_app_code != app_code: + return False + + session = self._sessions.find_active_session( + app_id=app_id, + app_code=token_app_code, + end_user_id=end_user_id, + ) + if session is None: + return False + if user_id is not None and session.end_user_session_id != user_id: + return False + + return self._verify_webapp_auth_claims(decoded, app_id) + except Exception: + return False + + def _verify_webapp_auth_claims(self, decoded: dict[str, Any], app_id: str) -> bool: + access_mode = self._app_access.get_access_mode(app_id=app_id, app_code=None) + expected_auth_type = resolve_web_app_auth_type(access_mode) + source = decoded.get("token_source") + if expected_auth_type != WebAppAuthType.PUBLIC: + if source != "webapp": + return False + user_id = decoded.get("user_id") + if not isinstance(user_id, str): + return False + if self._app_access.requires_permission_check(app_id) and not self._app_access.is_user_allowed( + user_id=user_id, + app_id=app_id, + ): + return False + auth_type = decoded.get("auth_type") + if auth_type != expected_auth_type: + return False + granted_at = decoded.get("granted_at") + if not isinstance(granted_at, (int, float)): + return False + granted_time = datetime.fromtimestamp(granted_at, tz=UTC) + if auth_type == WebAppAuthType.EXTERNAL: + return granted_time >= EnterpriseService.get_app_sso_settings_last_update_time() + if auth_type == WebAppAuthType.INTERNAL: + return granted_time >= EnterpriseService.get_workspace_sso_settings_last_update_time() + return False + return source != "webapp" + + +class LoggingWebAuthenticationAuditGateway(WebAuthenticationAuditGateway): + def __init__(self, *, logger: logging.Logger) -> None: + self._logger = logger + + @override + def login_failed(self, *, email: str, reason: LoginFailureReason, ip_address: str) -> None: + self._logger.warning( + "Web login failed: email=%s reason=%s ip_address=%s", + email, + reason, + ip_address, + ) diff --git a/api/services/web_authentication_service.py b/api/services/web_authentication_service.py new file mode 100644 index 00000000000..aae4f72de7d --- /dev/null +++ b/api/services/web_authentication_service.py @@ -0,0 +1,301 @@ +"""Framework-neutral application service for Web authentication use cases.""" + +from typing import Never, Protocol + +from enums import WebAppAccessMode +from machinery.context import RequestContext +from services.account_ports import AccountPasswordHasher +from services.entities.account_entities import AccountAuthenticationSnapshot, AccountPasswordDigest, AccountSnapshot +from services.entities.auth_audit_entities import LoginFailureReason +from services.entities.authentication_entities import ( + StoredAuthenticationToken, + WebAppSessionRecord, + WebLoginStatus, +) +from services.webapp_access_query_service import WebAppAccessAppNotFoundError + +_BANNED_ACCOUNT_STATUS = "banned" + + +class WebAuthenticationAccountRepository(Protocol): + def find_for_authentication(self, email: str) -> AccountAuthenticationSnapshot | None: ... + + def update_password(self, account_id: str, password: AccountPasswordDigest) -> AccountSnapshot | None: ... + + +class WebAuthenticationTokenGateway(Protocol): + def issue_access_token(self, *, account_id: str, email: str) -> str: ... + + def verify_access_token(self, token: str | None) -> bool: ... + + def send_email_login_code(self, *, account_id: str, email: str, language: str) -> str: ... + + def get_email_login_token(self, token: str) -> StoredAuthenticationToken | None: ... + + def revoke_email_login_token(self, token: str) -> None: ... + + def send_reset_password_code(self, *, account_id: str, email: str, language: str) -> str: ... + + def get_reset_password_token(self, token: str) -> StoredAuthenticationToken | None: ... + + def replace_reset_password_token(self, *, email: str, code: str) -> str: ... + + def revoke_reset_password_token(self, token: str) -> None: ... + + +class WebAuthenticationSecurityGateway(Protocol): + def is_email_send_ip_limited(self, ip_address: str) -> bool: ... + + def is_password_reset_verification_limited(self, email: str) -> bool: ... + + def record_password_reset_verification_failure(self, email: str) -> None: ... + + def reset_password_reset_verification_failures(self, email: str) -> None: ... + + def reset_login_failures(self, email: str) -> None: ... + + +class WebAppAccessGateway(Protocol): + def find_app_id_by_code(self, app_code: str) -> str | None: ... + + def requires_permission_check(self, app_id: str) -> bool: ... + + def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode: ... + + def is_user_allowed(self, *, user_id: str, app_id: str) -> bool: ... + + +class WebAppSessionGateway(Protocol): + def verify(self, *, token: str | None, app_code: str, user_id: str | None) -> bool: ... + + +class WebAppSessionQuery(Protocol): + def find_active_session( + self, + *, + app_id: str, + app_code: str, + end_user_id: str, + ) -> WebAppSessionRecord | None: ... + + +class WebAuthenticationAuditGateway(Protocol): + def login_failed(self, *, email: str, reason: LoginFailureReason, ip_address: str) -> None: ... + + +class WebAuthenticationError(Exception): + """Base class for Web authentication use-case failures.""" + + +class WebAuthenticationFailedError(WebAuthenticationError): + pass + + +class WebAccountBannedError(WebAuthenticationError): + pass + + +class WebInvalidTokenError(WebAuthenticationError): + pass + + +class WebInvalidEmailError(WebAuthenticationError): + pass + + +class WebInvalidCodeError(WebAuthenticationError): + pass + + +class WebPasswordMismatchError(WebAuthenticationError): + pass + + +class WebEmailSendIPLimitedError(WebAuthenticationError): + pass + + +class WebPasswordResetVerificationLimitedError(WebAuthenticationError): + pass + + +class WebEmailDeliveryRateLimitError(WebAuthenticationError): + def __init__(self, *, flow: str, retry_after_minutes: int) -> None: + super().__init__(flow, retry_after_minutes) + self.flow = flow + self.retry_after_minutes = retry_after_minutes + + +class WebAuthenticationService: + def __init__( + self, + *, + accounts: WebAuthenticationAccountRepository, + passwords: AccountPasswordHasher, + tokens: WebAuthenticationTokenGateway, + security: WebAuthenticationSecurityGateway, + app_access: WebAppAccessGateway, + app_sessions: WebAppSessionGateway, + audit: WebAuthenticationAuditGateway, + private_app_access_enabled: bool, + ) -> None: + self._accounts = accounts + self._passwords = passwords + self._tokens = tokens + self._security = security + self._app_access = app_access + self._app_sessions = app_sessions + self._audit = audit + self._private_app_access_enabled = private_app_access_enabled + + def login_with_password(self, context: RequestContext, *, email: str, password: str) -> str: + normalized_email = email.lower() + account = self._accounts.find_for_authentication(email) + if account is None: + self._fail_login(context, normalized_email, LoginFailureReason.ACCOUNT_NOT_FOUND) + self._ensure_active_account(context, account, normalized_email) + if ( + account.password_hash is None + or account.password_salt is None + or not self._passwords.verify( + password, + password_hash=account.password_hash, + password_salt=account.password_salt, + ) + ): + self._fail_login(context, normalized_email, LoginFailureReason.INVALID_CREDENTIALS) + return self._tokens.issue_access_token(account_id=account.id, email=account.email) + + def get_login_status( + self, + *, + app_code: str | None, + user_id: str | None, + access_token: str | None, + app_session_token: str | None, + ) -> WebLoginStatus: + if not app_code: + return WebLoginStatus(logged_in=bool(access_token), app_logged_in=False) + + app_id = self._app_access.find_app_id_by_code(app_code) + if app_id is None: + raise WebAppAccessAppNotFoundError(f"App with code {app_code} not found") + is_public = not self._private_app_access_enabled or not self._app_access.requires_permission_check(app_id) + logged_in = is_public or self._tokens.verify_access_token(access_token) + app_logged_in = self._app_sessions.verify(token=app_session_token, app_code=app_code, user_id=user_id) + return WebLoginStatus(logged_in=logged_in, app_logged_in=app_logged_in) + + def send_email_login_code(self, *, email: str, language: str | None) -> str: + account = self._accounts.find_for_authentication(email) + if account is None: + raise WebAuthenticationFailedError + if account.status == _BANNED_ACCOUNT_STATUS: + raise WebAccountBannedError + return self._tokens.send_email_login_code( + account_id=account.id, + email=account.email, + language=self._language(language), + ) + + def login_with_email_code( + self, + context: RequestContext, + *, + email: str, + code: str, + token: str, + ) -> str: + normalized_email = email.lower() + token_data = self._tokens.get_email_login_token(token) + if token_data is None: + self._fail_login(context, normalized_email, LoginFailureReason.INVALID_EMAIL_CODE_TOKEN) + if token_data.email is None or token_data.email.lower() != normalized_email: + self._fail_login(context, normalized_email, LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH) + if token_data.code != code: + self._fail_login(context, normalized_email, LoginFailureReason.INVALID_EMAIL_CODE) + + self._tokens.revoke_email_login_token(token) + account = self._accounts.find_for_authentication(token_data.email) + if account is None: + self._fail_login(context, normalized_email, LoginFailureReason.ACCOUNT_NOT_FOUND) + self._ensure_active_account(context, account, normalized_email) + access_token = self._tokens.issue_access_token(account_id=account.id, email=account.email) + self._security.reset_login_failures(normalized_email) + return access_token + + def send_reset_password_email(self, context: RequestContext, *, email: str, language: str | None) -> str: + if self._security.is_email_send_ip_limited(context.remote_ip or ""): + raise WebEmailSendIPLimitedError + account = self._accounts.find_for_authentication(email) + if account is None: + raise WebAuthenticationFailedError + return self._tokens.send_reset_password_code( + account_id=account.id, + email=account.email, + language=self._language(language), + ) + + def verify_reset_password_code(self, *, email: str, code: str, token: str) -> str: + normalized_email = email.lower() + if self._security.is_password_reset_verification_limited(normalized_email): + raise WebPasswordResetVerificationLimitedError + token_data = self._tokens.get_reset_password_token(token) + if token_data is None: + raise WebInvalidTokenError + if token_data.email is None or token_data.email.lower() != normalized_email: + raise WebInvalidEmailError + if token_data.code != code: + self._security.record_password_reset_verification_failure(normalized_email) + raise WebInvalidCodeError + + self._tokens.revoke_reset_password_token(token) + new_token = self._tokens.replace_reset_password_token(email=token_data.email, code=code) + self._security.reset_password_reset_verification_failures(normalized_email) + return new_token + + def reset_password(self, *, token: str, new_password: str, password_confirmation: str) -> None: + if new_password != password_confirmation: + raise WebPasswordMismatchError + token_data = self._tokens.get_reset_password_token(token) + if token_data is None or token_data.phase != "reset": + raise WebInvalidTokenError + + self._tokens.revoke_reset_password_token(token) + if token_data.email is None: + raise WebAuthenticationFailedError + account = self._accounts.find_for_authentication(token_data.email) + if account is None: + raise WebAuthenticationFailedError + password = self._passwords.hash(new_password) + if self._accounts.update_password(account.id, password) is None: + raise WebAuthenticationFailedError + + def _ensure_active_account( + self, + context: RequestContext, + account: AccountAuthenticationSnapshot, + normalized_email: str, + ) -> None: + if account.status == _BANNED_ACCOUNT_STATUS: + self._audit.login_failed( + email=normalized_email, + reason=LoginFailureReason.ACCOUNT_BANNED, + ip_address=context.remote_ip or "", + ) + raise WebAccountBannedError + + def _fail_login(self, context: RequestContext, email: str, reason: LoginFailureReason) -> Never: + self._audit.login_failed(email=email, reason=reason, ip_address=context.remote_ip or "") + if reason == LoginFailureReason.ACCOUNT_BANNED: + raise WebAccountBannedError + if reason == LoginFailureReason.INVALID_EMAIL_CODE_TOKEN: + raise WebInvalidTokenError + if reason == LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH: + raise WebInvalidEmailError + if reason == LoginFailureReason.INVALID_EMAIL_CODE: + raise WebInvalidCodeError + raise WebAuthenticationFailedError + + @staticmethod + def _language(language: str | None) -> str: + return "zh-Hans" if language == "zh-Hans" else "en-US" diff --git a/api/services/webapp_access_adapters.py b/api/services/webapp_access_adapters.py new file mode 100644 index 00000000000..d75478fa981 --- /dev/null +++ b/api/services/webapp_access_adapters.py @@ -0,0 +1,48 @@ +"""Infrastructure adapters for Web app access policy queries.""" + +import json +from typing import Protocol, override + +import httpx +from pydantic import ValidationError + +from enums import WebAppAccessMode +from services.enterprise.enterprise_service import WebAppSettings +from services.errors.enterprise import EnterpriseServiceError +from services.webapp_access_query_service import WebAppAccessPolicyGateway, WebAppAccessUnavailableError + + +class EnterpriseWebAppAuthService(Protocol): + def get_app_access_mode_by_id(self, app_id: str) -> WebAppSettings: ... + + def is_user_allowed_to_access_webapp(self, user_id: str, app_id: str) -> bool: ... + + +class EnterpriseWebAppAccessPolicyGateway(WebAppAccessPolicyGateway): + def __init__(self, *, webapp_auth: EnterpriseWebAppAuthService) -> None: + self._webapp_auth = webapp_auth + + @override + def get_access_mode(self, app_id: str) -> WebAppAccessMode: + try: + settings = self._webapp_auth.get_app_access_mode_by_id(app_id) + except ( + EnterpriseServiceError, + httpx.RequestError, + json.JSONDecodeError, + UnicodeDecodeError, + ValidationError, + ) as error: + raise WebAppAccessUnavailableError from error + + try: + return WebAppAccessMode(settings.access_mode) + except ValueError as error: + raise WebAppAccessUnavailableError from error + + @override + def is_user_allowed(self, *, user_id: str, app_id: str) -> bool: + try: + return self._webapp_auth.is_user_allowed_to_access_webapp(user_id, app_id) + except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError) as error: + raise WebAppAccessUnavailableError from error diff --git a/api/services/webapp_access_query_service.py b/api/services/webapp_access_query_service.py index ea9fe70a42b..61c25a2dd3c 100644 --- a/api/services/webapp_access_query_service.py +++ b/api/services/webapp_access_query_service.py @@ -1,6 +1,6 @@ """Application service for resolving web-app access.""" -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Mapping, Sequence from typing import Protocol from enums import WebAppAccessMode @@ -12,6 +12,12 @@ class WebAppAccessQuery(Protocol): def find_app_id_by_code(self, app_code: str) -> str | None: ... +class WebAppAccessPolicyGateway(Protocol): + def get_access_mode(self, app_id: str) -> WebAppAccessMode: ... + + def is_user_allowed(self, *, user_id: str, app_id: str) -> bool: ... + + class WebAppAccessModesQuery(Protocol): def __call__(self, *, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]: ... @@ -37,16 +43,14 @@ class WebAppAccessQueryService: self, *, access: WebAppAccessQuery, + policy: WebAppAccessPolicyGateway, webapp_auth_enabled: bool, - access_mode_for_app: Callable[[str], WebAppAccessMode], - is_user_allowed_for_app: Callable[[str, str], bool], get_access_modes: WebAppAccessModesQuery, get_user_permissions: WebAppUserPermissionsQuery, ) -> None: self._access: WebAppAccessQuery = access + self._policy: WebAppAccessPolicyGateway = policy self._webapp_auth_enabled: bool = webapp_auth_enabled - self._access_mode_for_app: Callable[[str], WebAppAccessMode] = access_mode_for_app - self._is_user_allowed_for_app: Callable[[str, str], bool] = is_user_allowed_for_app self._get_access_modes: WebAppAccessModesQuery = get_access_modes self._get_user_permissions: WebAppUserPermissionsQuery = get_user_permissions @@ -66,10 +70,16 @@ class WebAppAccessQueryService: if not app_id: raise WebAppAccessReferenceRequiredError("appId or appCode must be provided") - return self._access_mode_for_app(app_id) + return self._policy.get_access_mode(app_id) + + def find_app_id_by_code(self, app_code: str) -> str | None: + return self._access.find_app_id_by_code(app_code) def requires_permission_check(self, app_id: str) -> bool: - return self.is_permission_check_required(self._access_mode_for_app(app_id)) + return self.is_permission_check_required(self._policy.get_access_mode(app_id)) + + def requires_authentication(self, app_id: str) -> bool: + return self._webapp_auth_enabled and self._policy.get_access_mode(app_id) != WebAppAccessMode.PUBLIC @staticmethod def is_permission_check_required(access_mode: str) -> bool: @@ -79,7 +89,7 @@ class WebAppAccessQueryService: if not self._webapp_auth_enabled: return True - return self._is_user_allowed_for_app(user_id, app_id) + return self._policy.is_user_allowed(user_id=user_id, app_id=app_id) def batch_get_access_modes(self, *, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]: """Return successfully parsed modes; missing IDs do not grant access.""" diff --git a/api/services/webapp_auth_service.py b/api/services/webapp_auth_service.py index e26394dba5b..0036566051f 100644 --- a/api/services/webapp_auth_service.py +++ b/api/services/webapp_auth_service.py @@ -1,89 +1,14 @@ -import secrets -from datetime import UTC, datetime, timedelta -from typing import Any - from sqlalchemy import select from sqlalchemy.orm import Session -from werkzeug.exceptions import NotFound, Unauthorized +from werkzeug.exceptions import NotFound -from configs import dify_config -from libs.helper import TokenManager -from libs.passport import PassportService -from libs.password import compare_password -from models import Account, AccountStatus from models.enums import EndUserType from models.model import App, EndUser, Site -from services.account_service import AccountService -from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError -from tasks.mail_email_code_login import send_email_code_login_mail_task class WebAppAuthService: """Service for web app authentication.""" - @staticmethod - def authenticate(email: str, password: str, session: Session) -> Account: - """authenticate account with email and password""" - account = AccountService.get_account_by_email_with_case_fallback(email, session=session) - if not account: - raise AccountNotFoundError() - - if account.status == AccountStatus.BANNED: - raise AccountLoginError("Account is banned.") - - if account.password is None or not compare_password(password, account.password, account.password_salt): - raise AccountPasswordError("Invalid email or password.") - - return account - - @classmethod - def login(cls, account: Account) -> str: - access_token = cls._get_account_jwt_token(account=account) - - return access_token - - @classmethod - def get_user_through_email(cls, email: str, session: Session): - account = AccountService.get_account_by_email_with_case_fallback(email, session=session) - if not account: - return None - - if account.status == AccountStatus.BANNED: - raise Unauthorized("Account is banned.") - - return account - - @classmethod - def send_email_code_login_email( - cls, account: Account | None = None, email: str | None = None, language: str = "en-US" - ): - email = account.email if account else email - if email is None: - raise ValueError("Email must be provided.") - - code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) - token = TokenManager.generate_token( - account_id=account.id if account else None, - email=email, - token_type="email_code_login", - additional_data={"code": code}, - ) - send_email_code_login_mail_task.delay( - language=language, - to=account.email if account else email, - code=code, - ) - - return token - - @classmethod - def get_email_code_login_data(cls, token: str) -> dict[str, Any] | None: - return TokenManager.get_token_data(token, "email_code_login") - - @classmethod - def revoke_email_code_login_token(cls, token: str): - TokenManager.revoke_token(token, "email_code_login") - @classmethod def create_end_user(cls, app_code, email, session: Session) -> EndUser: site = session.scalar(select(Site).where(Site.code == app_code).limit(1)) @@ -105,20 +30,3 @@ class WebAppAuthService: session.commit() return end_user - - @classmethod - def _get_account_jwt_token(cls, account: Account) -> str: - exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES) - exp = int(exp_dt.timestamp()) - - payload = { - "sub": "Web API Passport", - "user_id": account.id, - "session_id": account.email, - "token_source": "webapp_login_token", - "auth_type": "internal", - "exp": exp, - } - - token: str = PassportService().issue(payload) - return token diff --git a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py index 38a8dc7e81b..c085469a1b5 100644 --- a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py @@ -1,728 +1,85 @@ -import time +"""Integration coverage for the remaining WebAppAuthService responsibilities.""" + import uuid -from unittest.mock import patch import pytest from faker import Faker from sqlalchemy.orm import Session -from werkzeug.exceptions import NotFound, Unauthorized +from werkzeug.exceptions import NotFound -from libs.password import hash_password from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.enums import AppStatus, CustomizeTokenStrategy from models.model import App, Site -from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError from services.webapp_auth_service import WebAppAuthService -from tests.test_containers_integration_tests.helpers import generate_valid_password -class TestWebAppAuthService: - """Integration tests for WebAppAuthService using testcontainers.""" - - @pytest.fixture - def mock_external_service_dependencies(self): - """Mock setup for external service dependencies.""" - with ( - patch("services.webapp_auth_service.PassportService") as mock_passport_service, - patch("services.webapp_auth_service.TokenManager") as mock_token_manager, - patch("services.webapp_auth_service.send_email_code_login_mail_task") as mock_mail_task, - ): - # Setup default mock returns - mock_passport_service.return_value.issue.return_value = "mock_jwt_token" - mock_token_manager.generate_token.return_value = "mock_token" - mock_token_manager.get_token_data.return_value = {"code": "123456"} - mock_mail_task.delay.return_value = None - yield { - "passport_service": mock_passport_service, - "token_manager": mock_token_manager, - "mail_task": mock_mail_task, - } - - def _create_test_account_and_tenant(self, db_session_with_containers: Session, mock_external_service_dependencies): - """ - Helper method to create a test account and tenant for testing. - - Args: - db_session_with_containers: Database session from testcontainers infrastructure - mock_external_service_dependencies: Mock dependencies - - Returns: - tuple: (account, tenant) - Created account and tenant instances - """ - fake = Faker() - import uuid - - # Create account with unique email to avoid collisions - unique_email = f"test_{uuid.uuid4().hex[:8]}@example.com" - account = Account( - email=unique_email, - name=fake.name(), - interface_language="en-US", - status=AccountStatus.ACTIVE, - ) - - db_session_with_containers.add(account) - db_session_with_containers.commit() - - # Create tenant for the account - tenant = Tenant( - name=fake.company(), - status=TenantStatus.NORMAL, - ) - db_session_with_containers.add(tenant) - db_session_with_containers.commit() - - # Create tenant-account join - join = TenantAccountJoin( +def _create_account_and_tenant(session: Session) -> tuple[Account, Tenant]: + fake = Faker() + account = Account( + email=f"test_{uuid.uuid4().hex[:8]}@example.com", + name=fake.name(), + interface_language="en-US", + status=AccountStatus.ACTIVE, + ) + tenant = Tenant(name=fake.company(), status=TenantStatus.NORMAL) + session.add_all([account, tenant]) + session.flush() + session.add( + TenantAccountJoin( tenant_id=tenant.id, account_id=account.id, role=TenantAccountRole.OWNER, current=True, ) - db_session_with_containers.add(join) - db_session_with_containers.commit() - - # Set current tenant for account - account.set_current_tenant_with_session(tenant, session=db_session_with_containers) - - return account, tenant - - def _create_test_account_with_password( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Helper method to create a test account with password for testing. - - Args: - db_session_with_containers: Database session from testcontainers infrastructure - mock_external_service_dependencies: Mock dependencies - - Returns: - tuple: (account, tenant, password) - Created account, tenant and password - """ - fake = Faker() - password = generate_valid_password(fake) - - # Create account with password - import uuid - - unique_email = f"test_{uuid.uuid4().hex[:8]}@example.com" - account = Account( - email=unique_email, - name=fake.name(), - interface_language="en-US", - status=AccountStatus.ACTIVE, - ) - - # Hash password - salt = b"test_salt_16_bytes" - password_hash = hash_password(password, salt) - - # Convert to base64 for storage - import base64 - - account.password = base64.b64encode(password_hash).decode() - account.password_salt = base64.b64encode(salt).decode() - - db_session_with_containers.add(account) - db_session_with_containers.commit() - - # Create tenant for the account - tenant = Tenant( - name=fake.company(), - status=TenantStatus.NORMAL, - ) - db_session_with_containers.add(tenant) - db_session_with_containers.commit() - - # Create tenant-account join - join = TenantAccountJoin( - tenant_id=tenant.id, - account_id=account.id, - role=TenantAccountRole.OWNER, - current=True, - ) - db_session_with_containers.add(join) - db_session_with_containers.commit() - - # Set current tenant for account - account.set_current_tenant_with_session(tenant, session=db_session_with_containers) - - return account, tenant, password - - def _create_test_app_and_site( - self, db_session_with_containers: Session, mock_external_service_dependencies, tenant - ): - """ - Helper method to create a test app and site for testing. - - Args: - db_session_with_containers: Database session from testcontainers infrastructure - mock_external_service_dependencies: Mock dependencies - tenant: Tenant instance to associate with - - Returns: - tuple: (app, site) - Created app and site instances - """ - fake = Faker() - - # Create app - app = App( - tenant_id=tenant.id, - name=fake.company(), - description=fake.text(max_nb_chars=100), - mode="chat", - icon_type="emoji", - icon="🤖", - icon_background="#FF6B6B", - api_rph=100, - api_rpm=10, - enable_site=True, - enable_api=True, - ) - - db_session_with_containers.add(app) - db_session_with_containers.commit() - - # Create site - site = Site( - app_id=app.id, - title=fake.company(), - code=fake.unique.lexify(text="??????"), - description=fake.text(max_nb_chars=100), - default_language="en-US", - status=AppStatus.NORMAL, - customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, - ) - db_session_with_containers.add(site) - db_session_with_containers.commit() - - return app, site - - def test_authenticate_success(self, db_session_with_containers: Session, mock_external_service_dependencies): - """ - Test successful authentication with valid email and password. - - This test verifies: - - Proper authentication with valid credentials - - Correct account return - - Database state consistency - """ - # Arrange: Create test data - account, tenant, password = self._create_test_account_with_password( - db_session_with_containers, mock_external_service_dependencies - ) - - # Act: Execute authentication - result = WebAppAuthService.authenticate(account.email, password, db_session_with_containers) - - # Assert: Verify successful authentication - assert result is not None - assert result.id == account.id - assert result.email == account.email - assert result.name == account.name - assert result.status == AccountStatus.ACTIVE - - # Verify database state - refreshed = db_session_with_containers.get(Account, result.id) - assert refreshed is not None - assert refreshed.password is not None - assert refreshed.password_salt is not None - - def test_authenticate_account_not_found( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test authentication with non-existent email. - - This test verifies: - - Proper error handling for non-existent accounts - - Correct exception type and message - """ - # Arrange: Generate a guaranteed non-existent email - # Use UUID and timestamp to ensure uniqueness - unique_id = str(uuid.uuid4()).replace("-", "") - timestamp = str(int(time.time() * 1000000)) # microseconds - non_existent_email = f"nonexistent_{unique_id}_{timestamp}@test-domain-that-never-exists.invalid" - - # Double-check this email doesn't exist in the database - existing_account = db_session_with_containers.query(Account).filter_by(email=non_existent_email).first() - assert existing_account is None, f"Test email {non_existent_email} already exists in database" - - # Act & Assert: Verify proper error handling - with pytest.raises(AccountNotFoundError): - WebAppAuthService.authenticate(non_existent_email, "any_password", db_session_with_containers) - - def test_authenticate_account_banned(self, db_session_with_containers: Session, mock_external_service_dependencies): - """ - Test authentication with banned account. - - This test verifies: - - Proper error handling for banned accounts - - Correct exception type and message - """ - # Arrange: Create banned account - fake = Faker() - password = generate_valid_password(fake) - unique_email = f"test_{uuid.uuid4().hex[:8]}@example.com" - - account = Account( - email=unique_email, - name=fake.name(), - interface_language="en-US", - status=AccountStatus.BANNED, - ) - - # Hash password - salt = b"test_salt_16_bytes" - password_hash = hash_password(password, salt) - - # Convert to base64 for storage - import base64 - - account.password = base64.b64encode(password_hash).decode() - account.password_salt = base64.b64encode(salt).decode() - - db_session_with_containers.add(account) - db_session_with_containers.commit() - - # Act & Assert: Verify proper error handling - with pytest.raises(AccountLoginError) as exc_info: - WebAppAuthService.authenticate(account.email, password, db_session_with_containers) - - assert "Account is banned." in str(exc_info.value) - - def test_authenticate_invalid_password( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test authentication with invalid password. - - This test verifies: - - Proper error handling for invalid passwords - - Correct exception type and message - """ - # Arrange: Create account with password - account, tenant, correct_password = self._create_test_account_with_password( - db_session_with_containers, mock_external_service_dependencies - ) - - # Act & Assert: Verify proper error handling with wrong password - with pytest.raises(AccountPasswordError) as exc_info: - WebAppAuthService.authenticate(account.email, "wrong_password", db_session_with_containers) - - assert "Invalid email or password." in str(exc_info.value) - - def test_authenticate_account_without_password( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test authentication for account without password. - - This test verifies: - - Proper error handling for accounts without password - - Correct exception type and message - """ - # Arrange: Create account without password - fake = Faker() - import uuid - - unique_email = f"test_{uuid.uuid4().hex[:8]}@example.com" - - account = Account( - email=unique_email, - name=fake.name(), - interface_language="en-US", - status=AccountStatus.ACTIVE, - ) - - db_session_with_containers.add(account) - db_session_with_containers.commit() - - # Act & Assert: Verify proper error handling - with pytest.raises(AccountPasswordError) as exc_info: - WebAppAuthService.authenticate(account.email, "any_password", db_session_with_containers) - - assert "Invalid email or password." in str(exc_info.value) - - def test_login_success(self, db_session_with_containers: Session, mock_external_service_dependencies): - """ - Test successful login and JWT token generation. - - This test verifies: - - Proper JWT token generation - - Correct token format and content - - Mock service integration - """ - # Arrange: Create test account - account, tenant = self._create_test_account_and_tenant( - db_session_with_containers, mock_external_service_dependencies - ) - - # Act: Execute login - result = WebAppAuthService.login(account) - - # Assert: Verify successful login - assert result is not None - assert result == "mock_jwt_token" - - # Verify mock service was called correctly - mock_external_service_dependencies["passport_service"].return_value.issue.assert_called_once() - call_args = mock_external_service_dependencies["passport_service"].return_value.issue.call_args[0][0] - - assert call_args["sub"] == "Web API Passport" - assert call_args["user_id"] == account.id - assert call_args["session_id"] == account.email - assert call_args["token_source"] == "webapp_login_token" - assert call_args["auth_type"] == "internal" - assert "exp" in call_args - - def test_get_user_through_email_success( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test successful user retrieval through email. - - This test verifies: - - Proper user retrieval by email - - Correct account return - - Database state consistency - """ - # Arrange: Create test data - account, tenant = self._create_test_account_and_tenant( - db_session_with_containers, mock_external_service_dependencies - ) - - # Act: Execute user retrieval - result = WebAppAuthService.get_user_through_email(account.email, db_session_with_containers) - - # Assert: Verify successful retrieval - assert result is not None - assert result.id == account.id - assert result.email == account.email - assert result.name == account.name - assert result.status == AccountStatus.ACTIVE - - # Verify database state - refreshed = db_session_with_containers.get(Account, result.id) - assert refreshed is not None - - def test_get_user_through_email_not_found( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test user retrieval with non-existent email. - - This test verifies: - - Proper handling for non-existent users - - Correct return value (None) - """ - # Arrange: Use non-existent email - non_existent_email = f"nonexistent_{uuid.uuid4().hex}@example.com" - - # Act: Execute user retrieval - result = WebAppAuthService.get_user_through_email(non_existent_email, db_session_with_containers) - - # Assert: Verify proper handling - assert result is None - - def test_get_user_through_email_banned( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test user retrieval with banned account. - - This test verifies: - - Proper error handling for banned accounts - - Correct exception type and message - """ - # Arrange: Create banned account - fake = Faker() - import uuid - - unique_email = f"test_{uuid.uuid4().hex[:8]}@example.com" - - account = Account( - email=unique_email, - name=fake.name(), - interface_language="en-US", - status=AccountStatus.BANNED, - ) - - db_session_with_containers.add(account) - db_session_with_containers.commit() - - # Act & Assert: Verify proper error handling - with pytest.raises(Unauthorized) as exc_info: - WebAppAuthService.get_user_through_email(account.email, db_session_with_containers) - - assert "Account is banned." in str(exc_info.value) - - def test_send_email_code_login_email_with_account( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test sending email code login email with account. - - This test verifies: - - Proper email code generation - - Token generation with correct data - - Mail task scheduling - - Mock service integration - """ - # Arrange: Create test account - account, tenant = self._create_test_account_and_tenant( - db_session_with_containers, mock_external_service_dependencies - ) - - # Act: Execute email code login email sending - result = WebAppAuthService.send_email_code_login_email(account=account, language="en-US") - - # Assert: Verify successful email sending - assert result is not None - assert result == "mock_token" - - # Verify mock services were called correctly - mock_external_service_dependencies["token_manager"].generate_token.assert_called_once() - mock_external_service_dependencies["mail_task"].delay.assert_called_once() - - # Verify token generation parameters - token_call_args = mock_external_service_dependencies["token_manager"].generate_token.call_args - assert token_call_args[1]["account_id"] == account.id - assert token_call_args[1]["email"] == account.email - assert token_call_args[1]["token_type"] == "email_code_login" - assert "code" in token_call_args[1]["additional_data"] - - # Verify mail task parameters - mail_call_args = mock_external_service_dependencies["mail_task"].delay.call_args - assert mail_call_args[1]["language"] == "en-US" - assert mail_call_args[1]["to"] == account.email - assert "code" in mail_call_args[1] - - def test_send_email_code_login_email_with_email_only( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test sending email code login email with email only. - - This test verifies: - - Proper email code generation without account - - Token generation with email only - - Mail task scheduling - - Mock service integration - """ - # Arrange: Use test email - fake = Faker() - test_email = fake.email() - - # Act: Execute email code login email sending - result = WebAppAuthService.send_email_code_login_email(email=test_email, language="zh-Hans") - - # Assert: Verify successful email sending - assert result is not None - assert result == "mock_token" - - # Verify mock services were called correctly - mock_external_service_dependencies["token_manager"].generate_token.assert_called_once() - mock_external_service_dependencies["mail_task"].delay.assert_called_once() - - # Verify token generation parameters - token_call_args = mock_external_service_dependencies["token_manager"].generate_token.call_args - assert token_call_args[1]["account_id"] is None - assert token_call_args[1]["email"] == test_email - assert token_call_args[1]["token_type"] == "email_code_login" - assert "code" in token_call_args[1]["additional_data"] - - # Verify mail task parameters - mail_call_args = mock_external_service_dependencies["mail_task"].delay.call_args - assert mail_call_args[1]["language"] == "zh-Hans" - assert mail_call_args[1]["to"] == test_email - assert "code" in mail_call_args[1] - - def test_send_email_code_login_email_no_email_provided( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test sending email code login email without providing email. - - This test verifies: - - Proper error handling when no email is provided - - Correct exception type and message - """ - # Arrange: No email provided - - # Act & Assert: Verify proper error handling - with pytest.raises(ValueError) as exc_info: - WebAppAuthService.send_email_code_login_email() - - assert "Email must be provided." in str(exc_info.value) - - def test_get_email_code_login_data_success( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test successful retrieval of email code login data. - - This test verifies: - - Proper token data retrieval - - Correct data format - - Mock service integration - """ - # Arrange: Setup mock return - expected_data = {"code": "123456", "email": "test@example.com"} - mock_external_service_dependencies["token_manager"].get_token_data.return_value = expected_data - - # Act: Execute data retrieval - result = WebAppAuthService.get_email_code_login_data("mock_token") - - # Assert: Verify successful retrieval - assert result is not None - assert result == expected_data - assert result["code"] == "123456" - assert result["email"] == "test@example.com" - - # Verify mock service was called correctly - mock_external_service_dependencies["token_manager"].get_token_data.assert_called_once_with( - "mock_token", "email_code_login" - ) - - def test_get_email_code_login_data_no_data( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test email code login data retrieval when no data exists. - - This test verifies: - - Proper handling when no token data exists - - Correct return value (None) - - Mock service integration - """ - # Arrange: Setup mock return for no data - mock_external_service_dependencies["token_manager"].get_token_data.return_value = None - - # Act: Execute data retrieval - result = WebAppAuthService.get_email_code_login_data("invalid_token") - - # Assert: Verify proper handling - assert result is None - - # Verify mock service was called correctly - mock_external_service_dependencies["token_manager"].get_token_data.assert_called_once_with( - "invalid_token", "email_code_login" - ) - - def test_revoke_email_code_login_token_success( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test successful revocation of email code login token. - - This test verifies: - - Proper token revocation - - Mock service integration - """ - # Arrange: Setup mock - - # Act: Execute token revocation - WebAppAuthService.revoke_email_code_login_token("mock_token") - - # Assert: Verify mock service was called correctly - mock_external_service_dependencies["token_manager"].revoke_token.assert_called_once_with( - "mock_token", "email_code_login" - ) - - def test_create_end_user_success(self, db_session_with_containers: Session, mock_external_service_dependencies): - """ - Test successful end user creation. - - This test verifies: - - Proper end user creation with valid app code - - Correct database state after creation - - Proper relationship establishment - - Mock service integration - """ - # Arrange: Create test data - account, tenant = self._create_test_account_and_tenant( - db_session_with_containers, mock_external_service_dependencies - ) - app, site = self._create_test_app_and_site( - db_session_with_containers, mock_external_service_dependencies, tenant - ) - - # Act: Execute end user creation - result = WebAppAuthService.create_end_user(site.code, "test@example.com", db_session_with_containers) - - # Assert: Verify successful creation - assert result is not None - assert result.tenant_id == app.tenant_id - assert result.app_id == app.id - assert result.type == "browser" - assert result.is_anonymous is False - assert result.session_id == "test@example.com" - assert result.name == "enterpriseuser" - assert result.external_user_id == "enterpriseuser" - - # Verify database state - - db_session_with_containers.refresh(result) - assert result.id is not None - assert result.created_at is not None - assert result.updated_at is not None - - def test_create_end_user_site_not_found( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test end user creation with non-existent site code. - - This test verifies: - - Proper error handling for non-existent sites - - Correct exception type and message - """ - # Arrange: Use non-existent site code - fake = Faker() - non_existent_code = fake.unique.lexify(text="??????") - - # Act & Assert: Verify proper error handling - with pytest.raises(NotFound) as exc_info: - WebAppAuthService.create_end_user(non_existent_code, "test@example.com", db_session_with_containers) - - assert "Site not found." in str(exc_info.value) - - def test_create_end_user_app_not_found( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test end user creation when app is not found. - - This test verifies: - - Proper error handling when app is missing - - Correct exception type and message - """ - # Arrange: Create site without app - fake = Faker() - tenant = Tenant( - name=fake.company(), - status="normal", - ) - - db_session_with_containers.add(tenant) - db_session_with_containers.commit() - - site = Site( - app_id="00000000-0000-0000-0000-000000000000", - title=fake.company(), - code=fake.unique.lexify(text="??????"), - description=fake.text(max_nb_chars=100), - default_language="en-US", - status=AppStatus.NORMAL, - customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, - ) - db_session_with_containers.add(site) - db_session_with_containers.commit() - - # Act & Assert: Verify proper error handling - with pytest.raises(NotFound) as exc_info: - WebAppAuthService.create_end_user(site.code, "test@example.com", db_session_with_containers) - - assert "App not found." in str(exc_info.value) + ) + session.commit() + return account, tenant + + +def _create_app_and_site(session: Session, tenant: Tenant) -> tuple[App, Site]: + fake = Faker() + app = App( + tenant_id=tenant.id, + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + enable_site=True, + enable_api=True, + ) + session.add(app) + session.flush() + site = Site( + app_id=app.id, + title=fake.company(), + code=fake.unique.lexify(text="??????"), + description=fake.text(max_nb_chars=100), + default_language="en-US", + status=AppStatus.NORMAL, + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + ) + session.add(site) + session.commit() + return app, site + + +def test_create_end_user(db_session_with_containers: Session) -> None: + _, tenant = _create_account_and_tenant(db_session_with_containers) + app, site = _create_app_and_site(db_session_with_containers, tenant) + + result = WebAppAuthService.create_end_user(site.code, "test@example.com", db_session_with_containers) + + assert result.tenant_id == app.tenant_id + assert result.app_id == app.id + assert result.type == "browser" + assert result.is_anonymous is False + assert result.session_id == "test@example.com" + + +def test_create_end_user_rejects_unknown_site(db_session_with_containers: Session) -> None: + with pytest.raises(NotFound, match="Site not found"): + WebAppAuthService.create_end_user("missing", "test@example.com", db_session_with_containers) diff --git a/api/tests/unit_tests/controllers/conftest.py b/api/tests/unit_tests/controllers/conftest.py index d0cdf0fa25f..5974afc04fd 100644 --- a/api/tests/unit_tests/controllers/conftest.py +++ b/api/tests/unit_tests/controllers/conftest.py @@ -14,8 +14,6 @@ from extensions.application_services.app import AppServices from extensions.ext_application_services import ( _batch_get_enterprise_webapp_access_modes, _batch_get_enterprise_webapp_user_permissions, - _get_enterprise_webapp_access_mode, - _is_enterprise_webapp_user_allowed, ) from repositories.app.console_repository import ConsoleAppRepository from repositories.webapp_access_query_repository import WebAppAccessQueryRepository @@ -24,7 +22,9 @@ from services.app.import_service import AppImportService from services.app.query_service import AppQueryService from services.credentials.query import CredentialQuery from services.data_source.provider_service import DatasourceProviderService +from services.enterprise.enterprise_service import EnterpriseService from services.tag_application_service import TagApplicationService +from services.webapp_access_adapters import EnterpriseWebAppAccessPolicyGateway from services.webapp_access_query_service import WebAppAccessQueryService @@ -111,8 +111,7 @@ def app_query_services( webapp_access=WebAppAccessQueryService( access=WebAppAccessQueryRepository(session_factory=sqlite_session_factory), webapp_auth_enabled=True, - access_mode_for_app=_get_enterprise_webapp_access_mode, - is_user_allowed_for_app=_is_enterprise_webapp_user_allowed, + policy=EnterpriseWebAppAccessPolicyGateway(webapp_auth=EnterpriseService.WebAppAuth), get_access_modes=_batch_get_enterprise_webapp_access_modes, get_user_permissions=_batch_get_enterprise_webapp_user_permissions, ), diff --git a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py index 5fde28d31cf..d825f5ab6aa 100644 --- a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py @@ -1,234 +1,171 @@ -"""Unit tests for controllers.web.forgot_password endpoints.""" +"""Controller-boundary tests for Web forgot-password endpoints.""" -from __future__ import annotations - -import base64 -from collections.abc import Iterator -from unittest.mock import ANY, patch +from dataclasses import dataclass +from inspect import unwrap +from typing import override import pytest -from flask import Flask +from controllers.console.auth.error import EmailCodeError, InvalidTokenError, PasswordMismatchError +from controllers.console.error import EmailSendIpLimitError +from controllers.web import forgot_password from controllers.web.forgot_password import ( ForgotPasswordCheckApi, ForgotPasswordResetApi, ForgotPasswordSendEmailApi, ) -from enums import DeploymentEdition -from models.account import Account -from models.engine import db -from services.entities.feature_entities import SystemFeatureModel -from tests.unit_tests.config_override import config_overrides_context +from machinery.context import RequestContext +from services.entities.auth_entities import ( + ForgotPasswordCheckPayload, + ForgotPasswordResetPayload, + ForgotPasswordSendPayload, +) +from services.web_authentication_service import ( + WebEmailSendIPLimitedError, + WebInvalidCodeError, + WebInvalidTokenError, + WebPasswordMismatchError, +) @pytest.fixture -def database_app() -> Iterator[Flask]: - app = Flask(__name__) - app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" - db.init_app(app) - - with app.app_context(): - Account.__table__.create(db.engine) - yield app +def context() -> RequestContext: + return RequestContext("request-1", "trace-1", "", "", "127.0.0.1") -@pytest.fixture(autouse=True) -def _patch_wraps(): - wraps_features = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_email_password_login=True, +@dataclass(frozen=True, slots=True) +class ApplicationServicesStub: + web_authentication: object + + +def bind_service(monkeypatch: pytest.MonkeyPatch, service: object) -> None: + monkeypatch.setattr( + forgot_password, + "application_services", + lambda: ApplicationServicesStub(web_authentication=service), ) - with ( - patch("controllers.console.wraps.db") as mock_db, - config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE), - patch( - "controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled", - return_value=wraps_features.enable_email_password_login, + + +class ForgotPasswordStub: + def __init__(self) -> None: + self.reset_call: tuple[str, str, str] | None = None + + def send_reset_password_email(self, context: RequestContext, *, email: str, language: str | None) -> str: + assert context.remote_ip == "127.0.0.1" + assert (email, language) == ("User@Example.com", "zh-Hans") + return "verification-token" + + def verify_reset_password_code(self, *, email: str, code: str, token: str) -> str: + assert (email, code, token) == ("User@Example.com", "1234", "verification-token") + return "reset-token" + + def reset_password(self, *, token: str, new_password: str, password_confirmation: str) -> None: + self.reset_call = (token, new_password, password_confirmation) + + +def test_forgot_password_endpoints_delegate_and_serialize( + monkeypatch: pytest.MonkeyPatch, + context: RequestContext, +) -> None: + service = ForgotPasswordStub() + bind_service(monkeypatch, service) + + send_result = unwrap(ForgotPasswordSendEmailApi.post)( + ForgotPasswordSendEmailApi(), + ForgotPasswordSendPayload(email="User@Example.com", language="zh-Hans"), + context, + ) + check_result = unwrap(ForgotPasswordCheckApi.post)( + ForgotPasswordCheckApi(), + ForgotPasswordCheckPayload(email="User@Example.com", code="1234", token="verification-token"), + context, + ) + reset_result = unwrap(ForgotPasswordResetApi.post)( + ForgotPasswordResetApi(), + ForgotPasswordResetPayload( + token="reset-token", + new_password="ValidPass123!", + password_confirm="ValidPass123!", ), - ): - yield + context, + ) + + assert send_result == {"result": "success", "data": "verification-token"} + assert check_result == {"is_valid": True, "email": "user@example.com", "token": "reset-token"} + assert reset_result == {"result": "success"} + assert service.reset_call == ("reset-token", "ValidPass123!", "ValidPass123!") -class TestForgotPasswordSendEmailApi: - @patch("controllers.web.forgot_password.AccountService.send_reset_password_email") - @patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.web.forgot_password.AccountService.is_email_send_ip_limit", return_value=False) - @patch("controllers.web.forgot_password.extract_remote_ip", return_value="127.0.0.1") - def test_should_normalize_email_before_sending( - self, - mock_extract_ip, - mock_rate_limit, - mock_get_account, - mock_send_mail, - app: Flask, - ): - account = Account(name="User", email="user@example.com") - mock_get_account.return_value = account - mock_send_mail.return_value = "token-123" +@pytest.mark.parametrize( + ("method", "service_error", "http_error"), + [ + pytest.param("send", WebEmailSendIPLimitedError(), EmailSendIpLimitError, id="ip-limit"), + pytest.param("check", WebInvalidCodeError(), EmailCodeError, id="invalid-code"), + pytest.param("check", WebInvalidTokenError(), InvalidTokenError, id="invalid-token"), + pytest.param("reset", WebPasswordMismatchError(), PasswordMismatchError, id="password-mismatch"), + ], +) +def test_forgot_password_translates_service_errors( + monkeypatch: pytest.MonkeyPatch, + context: RequestContext, + method: str, + service_error: Exception, + http_error: type[Exception], +) -> None: + class ErrorService(ForgotPasswordStub): + @override + def send_reset_password_email( + self, + context: RequestContext, + *, + email: str, + language: str | None, + ) -> str: + if method == "send": + raise service_error + return super().send_reset_password_email(context, email=email, language=language) - with app.test_request_context( - "/web/forgot-password", - method="POST", - json={"email": "User@Example.com", "language": "zh-Hans"}, - ): - response = ForgotPasswordSendEmailApi().post() + @override + def verify_reset_password_code(self, *, email: str, code: str, token: str) -> str: + if method == "check": + raise service_error + return super().verify_reset_password_code(email=email, code=code, token=token) - assert response == {"result": "success", "data": "token-123"} - mock_get_account.assert_called_once_with("User@Example.com", session=ANY) - mock_send_mail.assert_called_once_with(account=account, email="user@example.com", language="zh-Hans") - mock_extract_ip.assert_called_once() - mock_rate_limit.assert_called_once_with("127.0.0.1") + @override + def reset_password(self, *, token: str, new_password: str, password_confirmation: str) -> None: + if method == "reset": + raise service_error + super().reset_password( + token=token, + new_password=new_password, + password_confirmation=password_confirmation, + ) + bind_service(monkeypatch, ErrorService()) -class TestForgotPasswordCheckApi: - @patch("controllers.web.forgot_password.AccountService.reset_forgot_password_error_rate_limit") - @patch("controllers.web.forgot_password.AccountService.generate_reset_password_token") - @patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token") - @patch("controllers.web.forgot_password.AccountService.add_forgot_password_error_rate_limit") - @patch("controllers.web.forgot_password.AccountService.get_reset_password_data") - @patch("controllers.web.forgot_password.AccountService.is_forgot_password_error_rate_limit") - def test_should_normalize_email_for_validity_checks( - self, - mock_is_rate_limit, - mock_get_data, - mock_add_rate, - mock_revoke_token, - mock_generate_token, - mock_reset_rate, - app: Flask, - ): - mock_is_rate_limit.return_value = False - mock_get_data.return_value = {"email": "User@Example.com", "code": "1234"} - mock_generate_token.return_value = (None, "new-token") + def invoke() -> None: + if method == "send": + unwrap(ForgotPasswordSendEmailApi.post)( + ForgotPasswordSendEmailApi(), + ForgotPasswordSendPayload(email="User@Example.com", language="zh-Hans"), + context, + ) + elif method == "check": + unwrap(ForgotPasswordCheckApi.post)( + ForgotPasswordCheckApi(), + ForgotPasswordCheckPayload(email="User@Example.com", code="1234", token="verification-token"), + context, + ) + else: + unwrap(ForgotPasswordResetApi.post)( + ForgotPasswordResetApi(), + ForgotPasswordResetPayload( + token="reset-token", + new_password="ValidPass123!", + password_confirm="ValidPass123!", + ), + context, + ) - with app.test_request_context( - "/web/forgot-password/validity", - method="POST", - json={"email": "User@Example.com", "code": "1234", "token": "token-123"}, - ): - response = ForgotPasswordCheckApi().post() - - assert response == {"is_valid": True, "email": "user@example.com", "token": "new-token"} - mock_is_rate_limit.assert_called_once_with("user@example.com") - mock_add_rate.assert_not_called() - mock_revoke_token.assert_called_once_with("token-123") - mock_generate_token.assert_called_once_with( - "User@Example.com", - code="1234", - additional_data={"phase": "reset"}, - ) - mock_reset_rate.assert_called_once_with("user@example.com") - - @patch("controllers.web.forgot_password.AccountService.reset_forgot_password_error_rate_limit") - @patch("controllers.web.forgot_password.AccountService.generate_reset_password_token") - @patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token") - @patch("controllers.web.forgot_password.AccountService.get_reset_password_data") - @patch("controllers.web.forgot_password.AccountService.is_forgot_password_error_rate_limit") - def test_should_preserve_token_email_case( - self, - mock_is_rate_limit, - mock_get_data, - mock_revoke_token, - mock_generate_token, - mock_reset_rate, - app: Flask, - ): - mock_is_rate_limit.return_value = False - mock_get_data.return_value = {"email": "MixedCase@Example.com", "code": "5678"} - mock_generate_token.return_value = (None, "fresh-token") - - with app.test_request_context( - "/web/forgot-password/validity", - method="POST", - json={"email": "mixedcase@example.com", "code": "5678", "token": "token-upper"}, - ): - response = ForgotPasswordCheckApi().post() - - assert response == {"is_valid": True, "email": "mixedcase@example.com", "token": "fresh-token"} - mock_generate_token.assert_called_once_with( - "MixedCase@Example.com", - code="5678", - additional_data={"phase": "reset"}, - ) - mock_revoke_token.assert_called_once_with("token-upper") - mock_reset_rate.assert_called_once_with("mixedcase@example.com") - - -class TestForgotPasswordResetApi: - @patch("controllers.web.forgot_password.ForgotPasswordResetApi._update_existing_account") - @patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token") - @patch("controllers.web.forgot_password.AccountService.get_reset_password_data") - def test_should_fetch_account_with_fallback( - self, - mock_get_reset_data, - mock_revoke_token, - mock_get_account, - mock_update_account, - database_app: Flask, - ): - mock_get_reset_data.return_value = {"phase": "reset", "email": "User@Example.com", "code": "1234"} - account = Account(name="User", email="user@example.com") - db.session.add(account) - db.session.commit() - mock_get_account.return_value = account - - with database_app.test_request_context( - "/web/forgot-password/resets", - method="POST", - json={ - "token": "token-123", - "new_password": "ValidPass123!", - "password_confirm": "ValidPass123!", - }, - ): - response = ForgotPasswordResetApi().post() - - assert response == {"result": "success"} - mock_get_account.assert_called_once_with("User@Example.com", session=ANY) - mock_update_account.assert_called_once() - mock_revoke_token.assert_called_once_with("token-123") - - @patch("controllers.web.forgot_password.hash_password", return_value=b"hashed-value") - @patch("controllers.web.forgot_password.secrets.token_bytes", return_value=b"0123456789abcdef") - @patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token") - @patch("controllers.web.forgot_password.AccountService.get_reset_password_data") - @patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback") - def test_should_update_password_and_commit( - self, - mock_get_account, - mock_get_reset_data, - mock_revoke_token, - mock_token_bytes, - mock_hash_password, - database_app: Flask, - ): - mock_get_reset_data.return_value = {"phase": "reset", "email": "user@example.com"} - account = Account(name="User", email="user@example.com") - db.session.add(account) - db.session.commit() - mock_get_account.return_value = account - - with database_app.test_request_context( - "/web/forgot-password/resets", - method="POST", - json={ - "token": "reset-token", - "new_password": "StrongPass123!", - "password_confirm": "StrongPass123!", - }, - ): - response = ForgotPasswordResetApi().post() - - assert response == {"result": "success"} - mock_get_reset_data.assert_called_once_with("reset-token") - mock_revoke_token.assert_called_once_with("reset-token") - mock_token_bytes.assert_called_once_with(16) - mock_hash_password.assert_called_once_with("StrongPass123!", b"0123456789abcdef") - db.session.expire_all() - stored_account = db.session.get(Account, account.id) - assert stored_account is not None - expected_password = base64.b64encode(b"hashed-value").decode() - assert stored_account.password == expected_password - expected_salt = base64.b64encode(b"0123456789abcdef").decode() - assert stored_account.password_salt == expected_salt + with pytest.raises(http_error): + invoke() diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index 3974ccfa223..26b04323dbe 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -1,321 +1,246 @@ -import base64 -import logging -from types import SimpleNamespace -from unittest.mock import ANY, MagicMock, patch +"""Controller-boundary tests for Web login endpoints.""" + +from dataclasses import dataclass +from inspect import unwrap +from typing import override import pytest -from flask import Flask -from jwt import InvalidTokenError -from sqlalchemy.engine import Engine -from sqlalchemy.orm import Session, scoped_session, sessionmaker -from werkzeug.exceptions import Unauthorized +from flask import Flask, Response +from flask_restx import Api -import services.errors.account from controllers.console import wraps as console_wraps -from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi -from enums import DeploymentEdition -from models.account import Account -from models.model import DifySetup -from services.entities.auth_audit_entities import LoginFailureReason - -pytestmark = [ - pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True), - pytest.mark.usefixtures("app_query_services"), -] - - -def encode_code(code: str) -> str: - return base64.b64encode(code.encode("utf-8")).decode() - - -def assert_login_failure_logged(caplog: pytest.LogCaptureFixture, email: str, reason: LoginFailureReason) -> None: - records = [record for record in caplog.records if record.name == "controllers.web.login"] - assert len(records) == 1 - assert records[0].args[0] == email - assert records[0].args[1] == reason +from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError +from controllers.console.error import AccountBannedError +from controllers.web import login +from controllers.web.login import ( + EmailCodeLoginApi, + EmailCodeLoginSendEmailApi, + EmailCodeLoginSendPayload, + EmailCodeLoginVerifyPayload, + LoginApi, + LoginPayload, + LoginStatusApi, + LoginStatusQuery, + LogoutApi, +) +from machinery.context import RequestContext +from services.entities.authentication_entities import WebLoginStatus +from services.web_authentication_service import ( + WebAccountBannedError, + WebAuthenticationFailedError, + WebInvalidCodeError, +) +from services.webapp_access_query_service import WebAppAccessAppNotFoundError, WebAppAccessUnavailableError @pytest.fixture -def app(): - flask_app = Flask(__name__) - flask_app.config["TESTING"] = True - return flask_app +def app() -> Flask: + return Flask(__name__) -@pytest.fixture(autouse=True) -def _patch_wraps( +@pytest.fixture +def context() -> RequestContext: + return RequestContext("request-1", "trace-1", "", "", "127.0.0.1") + + +@dataclass(frozen=True, slots=True) +class ApplicationServicesStub: + web_authentication: object + + +def bind_service(monkeypatch: pytest.MonkeyPatch, service: object) -> None: + monkeypatch.setattr(login, "application_services", lambda: ApplicationServicesStub(web_authentication=service)) + + +class PasswordLoginStub: + def __init__(self, error: Exception | None = None) -> None: + self.error = error + self.call: tuple[RequestContext, str, str] | None = None + + def login_with_password(self, context: RequestContext, *, email: str, password: str) -> str: + self.call = (context, email, password) + if self.error is not None: + raise self.error + return "access-token" + + +def test_password_login_delegates_to_application_service( monkeypatch: pytest.MonkeyPatch, - sqlite_engine: Engine, - sqlite_session: Session, -): - wraps_features = SimpleNamespace(enable_email_password_login=True) - console_dify = SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - web_dify = SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - sqlite_session.add(DifySetup(version="test")) - sqlite_session.commit() - console_wraps._is_setup_completed.reset_success() - session_registry = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False)) - monkeypatch.setattr(console_wraps.db, "session", session_registry) - with ( - patch("controllers.console.wraps.dify_config", console_dify), - patch( - "controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled", - return_value=wraps_features.enable_email_password_login, - ), - patch("controllers.web.login.dify_config", web_dify), - ): - yield - session_registry.remove() - console_wraps._is_setup_completed.reset_success() + context: RequestContext, +) -> None: + service = PasswordLoginStub() + bind_service(monkeypatch, service) - -class TestEmailCodeLoginSendEmailApi: - @patch("controllers.web.login.WebAppAuthService.send_email_code_login_email") - @patch("controllers.web.login.WebAppAuthService.get_user_through_email") - def test_should_fetch_account_with_original_email( - self, - mock_get_user, - mock_send_email, - app: Flask, - ): - account = Account(name="Test User", email="user@example.com") - mock_get_user.return_value = account - mock_send_email.return_value = "token-123" - - with app.test_request_context( - "/web/email-code-login", - method="POST", - json={"email": "User@Example.com", "language": "en-US"}, - ): - response = EmailCodeLoginSendEmailApi().post() - - assert response == {"result": "success", "data": "token-123"} - mock_get_user.assert_called_once_with("User@Example.com", ANY) - mock_send_email.assert_called_once_with(account=account, language="en-US") - - -class TestEmailCodeLoginApi: - @patch("controllers.web.login.AccountService.reset_login_error_rate_limit") - @patch("controllers.web.login.WebAppAuthService.login", return_value="new-access-token") - @patch("controllers.web.login.WebAppAuthService.get_user_through_email") - @patch("controllers.web.login.WebAppAuthService.revoke_email_code_login_token") - @patch("controllers.web.login.WebAppAuthService.get_email_code_login_data") - def test_should_normalize_email_before_validating( - self, - mock_get_token_data, - mock_revoke_token, - mock_get_user, - mock_login, - mock_reset_login_rate, - app: Flask, - ): - mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"} - mock_get_user.return_value = Account(name="Test User", email="user@example.com") - - with app.test_request_context( - "/web/email-code-login/validity", - method="POST", - json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"}, - ): - response = EmailCodeLoginApi().post() - - assert response == {"result": "success", "data": {"access_token": "new-access-token"}} - mock_get_user.assert_called_once_with("User@Example.com", ANY) - mock_revoke_token.assert_called_once_with("token-123") - mock_login.assert_called_once() - mock_reset_login_rate.assert_called_once_with("user@example.com") - - -class TestLoginApi: - @patch("controllers.web.login.WebAppAuthService.login", return_value="access-tok") - @patch("controllers.web.login.WebAppAuthService.authenticate") - def test_login_success(self, mock_auth: MagicMock, mock_login: MagicMock, app: Flask) -> None: - mock_auth.return_value = Account(name="Test User", email="user@example.com") - - with app.test_request_context( - "/web/login", - method="POST", - json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()}, - ): - response = LoginApi().post() - - assert response["data"]["access_token"] == "access-tok" - mock_auth.assert_called_once() - - @patch( - "controllers.web.login.WebAppAuthService.authenticate", - side_effect=services.errors.account.AccountLoginError(), + result = unwrap(LoginApi.post)( + LoginApi(), + LoginPayload(email="User@Example.com", password="Valid1234"), + context, ) - def test_login_banned_account(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None: - from controllers.console.error import AccountBannedError - with caplog.at_level(logging.WARNING, logger="controllers.web.login"): - with app.test_request_context( - "/web/login", - method="POST", - json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()}, - ): - with pytest.raises(AccountBannedError): - LoginApi().post() - - assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.ACCOUNT_BANNED) - - @patch( - "controllers.web.login.WebAppAuthService.authenticate", - side_effect=services.errors.account.AccountPasswordError(), - ) - def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None: - from controllers.console.auth.error import AuthenticationFailedError - - with caplog.at_level(logging.WARNING, logger="controllers.web.login"): - with app.test_request_context( - "/web/login", - method="POST", - json={"email": "user@example.com", "password": base64.b64encode(b"Valid1234").decode()}, - ): - with pytest.raises(AuthenticationFailedError): - LoginApi().post() - - assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.INVALID_CREDENTIALS) - - @patch( - "controllers.web.login.WebAppAuthService.authenticate", - side_effect=services.errors.account.AccountNotFoundError(), - ) - def test_login_account_not_found(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None: - from controllers.console.auth.error import AuthenticationFailedError - - with caplog.at_level(logging.WARNING, logger="controllers.web.login"): - with app.test_request_context( - "/web/login", - method="POST", - json={"email": "missing@example.com", "password": base64.b64encode(b"Valid1234").decode()}, - ): - with pytest.raises(AuthenticationFailedError): - LoginApi().post() - - assert_login_failure_logged(caplog, "missing@example.com", LoginFailureReason.ACCOUNT_NOT_FOUND) - - @patch("controllers.web.login.WebAppAuthService.get_email_code_login_data", return_value=None) - def test_email_code_login_logs_invalid_token( - self, mock_get_token_data: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture - ) -> None: - with caplog.at_level(logging.WARNING, logger="controllers.web.login"): - with app.test_request_context( - "/web/email-code-login/validity", - method="POST", - json={"email": "user@example.com", "code": encode_code("123456"), "token": "token-123"}, - ): - with pytest.raises(InvalidTokenError): - EmailCodeLoginApi().post() - - mock_get_token_data.assert_called_once_with("token-123") - assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.INVALID_EMAIL_CODE_TOKEN) - - @patch("controllers.web.login.WebAppAuthService.revoke_email_code_login_token") - @patch( - "controllers.web.login.WebAppAuthService.get_user_through_email", - side_effect=Unauthorized("Account is banned."), - ) - @patch( - "controllers.web.login.WebAppAuthService.get_email_code_login_data", - return_value={"email": "User@Example.com", "code": "123456"}, - ) - def test_email_code_login_logs_banned_account( - self, - mock_get_token_data: MagicMock, - mock_get_user: MagicMock, - mock_revoke_token: MagicMock, - app: Flask, - caplog: pytest.LogCaptureFixture, - ) -> None: - from controllers.console.error import AccountBannedError - - with caplog.at_level(logging.WARNING, logger="controllers.web.login"): - with app.test_request_context( - "/web/email-code-login/validity", - method="POST", - json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"}, - ): - with pytest.raises(AccountBannedError): - EmailCodeLoginApi().post() - - mock_get_token_data.assert_called_once_with("token-123") - mock_revoke_token.assert_called_once_with("token-123") - assert_login_failure_logged(caplog, "user@example.com", LoginFailureReason.ACCOUNT_BANNED) + assert result == {"result": "success", "data": {"access_token": "access-token"}} + assert service.call == (context, "User@Example.com", "Valid1234") -class TestLoginStatusApi: - @patch("controllers.web.login.extract_webapp_access_token", return_value=None) - def test_no_app_code_returns_logged_in_false(self, mock_extract: MagicMock, app: Flask) -> None: - with app.test_request_context("/web/login/status"): - result = LoginStatusApi().get() +@pytest.mark.parametrize( + ("service_error", "http_error"), + [ + pytest.param(WebAccountBannedError(), AccountBannedError, id="banned"), + pytest.param(WebAuthenticationFailedError(), AuthenticationFailedError, id="credentials"), + ], +) +def test_password_login_translates_service_errors( + monkeypatch: pytest.MonkeyPatch, + context: RequestContext, + service_error: Exception, + http_error: type[Exception], +) -> None: + bind_service(monkeypatch, PasswordLoginStub(service_error)) - assert result["logged_in"] is False - assert result["app_logged_in"] is False - - @patch("controllers.web.login.decode_jwt_token") - @patch("controllers.web.login.PassportService") - @patch( - "services.webapp_access_query_service.WebAppAccessQueryService.requires_permission_check", return_value=False - ) - @patch("services.webapp_access_query_service.WebAppAccessQueryService.get_app_id_by_code", return_value="app-1") - @patch("controllers.web.login.extract_webapp_access_token", return_value="tok") - def test_public_app_user_logged_in( - self, - mock_extract: MagicMock, - mock_app_id: MagicMock, - mock_perm: MagicMock, - mock_passport: MagicMock, - mock_decode: MagicMock, - app: Flask, - ) -> None: - mock_decode.return_value = (MagicMock(), MagicMock()) - - with app.test_request_context("/web/login/status?app_code=code1"): - result = LoginStatusApi().get() - - assert result["logged_in"] is True - assert result["app_logged_in"] is True - - @patch("controllers.web.login.decode_jwt_token", side_effect=Exception("bad")) - @patch("controllers.web.login.PassportService") - @patch("services.webapp_access_query_service.WebAppAccessQueryService.requires_permission_check", return_value=True) - @patch("services.webapp_access_query_service.WebAppAccessQueryService.get_app_id_by_code", return_value="app-1") - @patch("controllers.web.login.extract_webapp_access_token", return_value="tok") - def test_private_app_passport_fails( - self, - mock_extract: MagicMock, - mock_app_id: MagicMock, - mock_perm: MagicMock, - mock_passport_cls: MagicMock, - mock_decode: MagicMock, - app: Flask, - ) -> None: - mock_passport_cls.return_value.verify.side_effect = Exception("bad") - - with app.test_request_context("/web/login/status?app_code=code1"): - result = LoginStatusApi().get() - - assert result["logged_in"] is False - assert result["app_logged_in"] is False + with pytest.raises(http_error): + unwrap(LoginApi.post)( + LoginApi(), + LoginPayload(email="user@example.com", password="Valid1234"), + context, + ) -class TestLogoutApi: - @patch("controllers.web.login.clear_webapp_access_token_from_cookie") - def test_logout_success(self, mock_clear: MagicMock, app: Flask) -> None: - with app.test_request_context("/web/logout", method="POST"): - response = LogoutApi().post() +class LoginStatusStub: + def __init__(self) -> None: + self.call: dict[str, str | None] | None = None - assert response.get_json() == {"result": "success"} - mock_clear.assert_called_once() + def get_login_status(self, **kwargs: str | None) -> WebLoginStatus: + self.call = kwargs + return WebLoginStatus(logged_in=True, app_logged_in=False) -def test_unknown_web_app_code_returns_http_not_found(app: Flask) -> None: - from flask_restx import Api +def test_login_status_passes_transport_tokens_to_service(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: + service = LoginStatusStub() + bind_service(monkeypatch, service) + monkeypatch.setattr(login, "extract_webapp_access_token", lambda _request: "account-token") + monkeypatch.setattr(login, "extract_webapp_passport", lambda app_code, _request: f"passport:{app_code}") + with app.test_request_context("/login/status?app_code=site-code"): + result = unwrap(LoginStatusApi.get)( + LoginStatusApi(), + LoginStatusQuery(app_code="site-code", user_id="session-1"), + RequestContext("request-1", None, "", "", "127.0.0.1"), + ) + + assert result == {"logged_in": True, "app_logged_in": False} + assert service.call == { + "app_code": "site-code", + "user_id": "session-1", + "access_token": "account-token", + "app_session_token": "passport:site-code", + } + + +@pytest.mark.parametrize( + ("service_error", "status_code", "error_code"), + [ + pytest.param(WebAppAccessAppNotFoundError(), 404, "app_not_found", id="unknown-app"), + pytest.param(WebAppAccessUnavailableError(), 503, "web_app_access_unavailable", id="access-unavailable"), + ], +) +def test_login_status_translates_access_failures_to_http_errors( + monkeypatch: pytest.MonkeyPatch, + app: Flask, + service_error: Exception, + status_code: int, + error_code: str, +) -> None: + class UnavailableLoginStatusService(LoginStatusStub): + @override + def get_login_status(self, **kwargs: str | None) -> WebLoginStatus: + raise service_error + + bind_service(monkeypatch, UnavailableLoginStatusService()) + monkeypatch.setattr(console_wraps, "_is_setup_completed", lambda: True) api = Api(app) api.add_resource(LoginStatusApi, "/web/login/status") + response = app.test_client().get("/web/login/status?app_code=does-not-exist") - assert response.status_code == 404 - assert response.get_json()["code"] == "app_not_found" + + assert response.status_code == status_code + assert response.get_json()["code"] == error_code + + +class EmailLoginStub: + def send_email_login_code(self, *, email: str, language: str | None) -> str: + assert (email, language) == ("User@Example.com", "zh-Hans") + return "email-token" + + def login_with_email_code( + self, + context: RequestContext, + *, + email: str, + code: str, + token: str, + ) -> str: + assert context.remote_ip == "127.0.0.1" + assert (email, code, token) == ("User@Example.com", "123456", "email-token") + return "access-token" + + +def test_email_login_endpoints_delegate_to_application_service( + monkeypatch: pytest.MonkeyPatch, + context: RequestContext, +) -> None: + bind_service(monkeypatch, EmailLoginStub()) + + send_result = unwrap(EmailCodeLoginSendEmailApi.post)( + EmailCodeLoginSendEmailApi(), + EmailCodeLoginSendPayload(email="User@Example.com", language="zh-Hans"), + context, + ) + login_result = unwrap(EmailCodeLoginApi.post)( + EmailCodeLoginApi(), + EmailCodeLoginVerifyPayload(email="User@Example.com", code="123456", token="email-token"), + context, + ) + + assert send_result == {"result": "success", "data": "email-token"} + assert login_result == {"result": "success", "data": {"access_token": "access-token"}} + + +def test_email_login_translates_invalid_code(monkeypatch: pytest.MonkeyPatch, context: RequestContext) -> None: + class InvalidCodeService(EmailLoginStub): + @override + def login_with_email_code( + self, + context: RequestContext, + *, + email: str, + code: str, + token: str, + ) -> str: + del context, email, code, token + raise WebInvalidCodeError + + bind_service(monkeypatch, InvalidCodeService()) + + with pytest.raises(EmailCodeError): + unwrap(EmailCodeLoginApi.post)( + EmailCodeLoginApi(), + EmailCodeLoginVerifyPayload(email="user@example.com", code="bad", token="email-token"), + context, + ) + + +def test_logout_only_serializes_response_and_clears_cookie( + monkeypatch: pytest.MonkeyPatch, + app: Flask, + context: RequestContext, +) -> None: + cleared: list[tuple[str | None, str]] = [] + + def clear_cookie(response: Response, *, samesite: str) -> None: + cleared.append((response.get_json()["result"], samesite)) + + monkeypatch.setattr(login, "clear_webapp_access_token_from_cookie", clear_cookie) + with app.test_request_context("/logout", method="POST"): + response = unwrap(LogoutApi.post)(LogoutApi(), context) + + assert response.get_json() == {"result": "success"} + assert cleared == [("success", "None")] diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index 1362d844e47..fabb2648720 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -87,6 +87,7 @@ from services.account_oauth_adapters import ( DeploymentOAuthPolicyGateway, RedisOAuthAccountClaimLock, ) +from services.account_service import AccountService from services.app.api_key_service import AppApiKeyService from services.app.creators_platform_gateway import CreatorsPlatformGateway from services.app_generate_service import AppGenerateService @@ -105,11 +106,16 @@ from services.data_source.binding_application_service import DataSourceBindingAp from services.data_source.credential_gateway import ActorAwareDatasourceCredentialGateway from services.data_source.notion_import_adapters import PluginNotionSourceGateway from services.data_source.notion_import_application_service import NotionImportApplicationService +from services.enterprise.enterprise_service import EnterpriseService from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPINotFoundError, EnterpriseServiceError from services.file_service import FileService from services.human_input_file_upload_service import HumanInputFileUploadService from services.init_validation_service import InvalidInitializationPasswordError -from services.installed_app_access_service import InstalledAppAccessDeniedError, InstalledAppRef +from services.installed_app_access_service import ( + InstalledAppAccessDeniedError, + InstalledAppAccessService, + InstalledAppRef, +) from services.installed_app_generation_adapters import AppGenerateServiceRuntime as InstalledAppGenerateServiceRuntime from services.installed_app_generation_service import InstalledAppGenerationService from services.knowledge.api_key_service import DatasetApiKeyService @@ -132,6 +138,13 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi from services.tag_application_service import TagApplicationService from services.tool_file_download_service import ToolFileDownloadService from services.upload_file_delivery_service import UploadFileDeliveryService +from services.web_authentication_adapters import ( + AccountServiceWebAuthenticationSecurityGateway, + PassportWebAppSessionGateway, + TokenManagerWebAuthenticationGateway, +) +from services.web_authentication_service import WebAuthenticationService +from services.webapp_access_adapters import EnterpriseWebAppAccessPolicyGateway from services.webapp_access_query_service import WebAppAccessQueryService, WebAppAccessUnavailableError from services.workflow_app_log_query_service import WorkflowAppLogQueryService from services.workflow_run_service import WorkflowRunService @@ -472,6 +485,49 @@ def test_build_application_services_wires_app_site_boundary( assert services.app_sites._sites._session_factory is sqlite_session_factory +def test_build_application_services_wires_web_authentication_boundary( + sqlite_session_factory: sessionmaker[Session], +) -> None: + services = ext_application_services.build_application_services( + database_client=sqlite_session_factory, + deployment_edition=DeploymentEdition.ENTERPRISE, + initialization_password="", + redis=MagicMock(spec=RedisClientWrapper), + ) + + assert isinstance(services.web_authentication, WebAuthenticationService) + assert services.web_authentication._accounts is services.accounts.profile._accounts + assert isinstance(services.web_authentication._tokens, TokenManagerWebAuthenticationGateway) + assert ( + services.web_authentication._tokens._access_token_expire_minutes + == ext_application_services.dify_config.ACCESS_TOKEN_EXPIRE_MINUTES + ) + assert isinstance(services.web_authentication._security, AccountServiceWebAuthenticationSecurityGateway) + assert services.web_authentication._security._account_service is AccountService + assert isinstance(services.web_authentication._app_sessions, PassportWebAppSessionGateway) + assert services.web_authentication._app_sessions._sessions is services.webapp_access._access + assert isinstance(services.webapp_access._policy, EnterpriseWebAppAccessPolicyGateway) + assert services.webapp_access._policy._webapp_auth is EnterpriseService.WebAppAuth + + +def test_build_application_services_reuses_installed_app_dependencies( + sqlite_session_factory: sessionmaker[Session], +) -> None: + services = ext_application_services.build_application_services( + database_client=sqlite_session_factory, + deployment_edition=DeploymentEdition.COMMUNITY, + initialization_password="", + redis=MagicMock(spec=RedisClientWrapper), + ) + + assert isinstance(services.installed_apps.access, InstalledAppAccessService) + assert isinstance(services.installed_apps.generation, InstalledAppGenerationService) + assert isinstance(services.installed_apps.access._installed_apps, SQLAlchemyInstalledAppRepository) + assert services.installed_apps.generation._usage is services.installed_apps.access._installed_apps + assert isinstance(services.installed_apps.generation._runtime, InstalledAppGenerateServiceRuntime) + assert services.installed_apps.generation._runtime._session_factory is sqlite_session_factory + + def test_build_application_services_wires_app_tracing_config_boundary( sqlite_session_factory: sessionmaker[Session], ) -> None: @@ -1335,6 +1391,7 @@ def test_build_application_services_wires_webapp_permission( def test_webapp_permission_adapter_maps_connection_failure() -> None: failure = httpx.ConnectError("connection failed") + adapter = EnterpriseWebAppAccessPolicyGateway(webapp_auth=EnterpriseService.WebAppAuth) with ( patch( "extensions.ext_application_services.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp", @@ -1342,7 +1399,7 @@ def test_webapp_permission_adapter_maps_connection_failure() -> None: ), pytest.raises(WebAppAccessUnavailableError) as raised, ): - ext_application_services._is_enterprise_webapp_user_allowed("user-1", "app-1") + adapter.is_user_allowed(user_id="user-1", app_id="app-1") assert raised.value.__cause__ is failure diff --git a/api/tests/unit_tests/repositories/test_account_repository.py b/api/tests/unit_tests/repositories/test_account_repository.py index 613e39352b5..c7158b18fff 100644 --- a/api/tests/unit_tests/repositories/test_account_repository.py +++ b/api/tests/unit_tests/repositories/test_account_repository.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session, sessionmaker from models.account import Account, AccountIntegrate, AccountStatus, InvitationCode, InvitationCodeStatus from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository from repositories.account_repository import SQLAlchemyAccountRepository +from services.account_email import normalize_email from services.entities.account_entities import AccountInitialization, AccountPasswordDigest, AccountProfileChanges from services.entities.account_login_entities import ( AccountSessionPreparation, @@ -102,6 +103,91 @@ def test_account_repository_updates_password( assert persisted.password_salt == "new-salt" +@pytest.mark.parametrize( + ("stored_email", "email"), + [ + ("account@example.com", "account@example.com"), + ("account@example.com", "Account@Example.com"), + ("Account@Example.com", "account@example.com"), + ("Account@Example.com", "ACCOUNT@EXAMPLE.COM"), + ("first.last@gmail.com", "firstlast+login@googlemail.com"), + ], +) +def test_account_repository_finds_authentication_snapshot_with_normalized_fallback( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + stored_email: str, + email: str, +) -> None: + account = Account(name="Original", email=stored_email, normalized_email=normalize_email(stored_email)) + account.id = "account-1" + account.password = "stored-hash" + account.password_salt = "stored-salt" + sqlite_session.add(account) + sqlite_session.commit() + repository = SQLAlchemyAccountRepository(sqlite_session_factory) + + result = repository.find_for_authentication(email) + + assert result is not None + assert result.id == "account-1" + assert result.email == stored_email + assert result.status == "active" + assert result.password_hash == "stored-hash" + assert result.password_salt == "stored-salt" + + +@pytest.mark.parametrize( + ("email", "expected_account_id"), + [ + ("Account@example.com", "older"), + ("account@example.com", "newer"), + ("ACCOUNT@EXAMPLE.COM", "older"), + ], +) +def test_account_repository_prefers_exact_authentication_match_then_oldest_normalized_account( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + email: str, + expected_account_id: str, +) -> None: + older = Account(name="Older", email="Account@example.com", normalized_email="account@example.com") + older.id = "older" + older.created_at = datetime(2026, 9, 1) + newer = Account(name="Newer", email="account@example.com", normalized_email="account@example.com") + newer.id = "newer" + newer.created_at = datetime(2026, 9, 2) + sqlite_session.add_all([newer, older]) + sqlite_session.commit() + repository = SQLAlchemyAccountRepository(sqlite_session_factory) + + result = repository.find_for_authentication(email) + + assert result is not None + assert result.id == expected_account_id + + +def test_account_repository_rejects_duplicate_exact_authentication_emails( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_account(sqlite_session) + sqlite_session.add(Account(name="Duplicate", email="account@example.com")) + sqlite_session.commit() + repository = SQLAlchemyAccountRepository(sqlite_session_factory) + + with pytest.raises(MultipleResultsFound): + repository.find_for_authentication("account@example.com") + + +def test_account_repository_returns_none_for_missing_authentication_account( + sqlite_session_factory: sessionmaker[Session], +) -> None: + repository = SQLAlchemyAccountRepository(sqlite_session_factory) + + assert repository.find_for_authentication("missing@example.com") is None + + def test_account_repository_returns_exact_then_lowercase_login_candidates( sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], diff --git a/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py b/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py index 02c7d3d7546..1f405ef811d 100644 --- a/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py +++ b/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py @@ -1,15 +1,56 @@ from unittest.mock import MagicMock import pytest +from sqlalchemy import select from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session, sessionmaker -from models.enums import CustomizeTokenStrategy -from models.model import Site +from models.enums import CustomizeTokenStrategy, EndUserType +from models.model import App, AppMode, EndUser, Site from repositories.webapp_access_query_repository import WebAppAccessQueryRepository from services.webapp_access_query_service import WebAppAccessUnavailableError _APP_ID = "11111111-1111-1111-1111-111111111111" +_TENANT_ID = "22222222-2222-2222-2222-222222222222" +_END_USER_ID = "33333333-3333-3333-3333-333333333333" +_OTHER_APP_ID = "44444444-4444-4444-4444-444444444444" +_OTHER_TENANT_ID = "55555555-5555-5555-5555-555555555555" + + +def _persist_webapp_session(session: Session, *, enable_site: bool = True) -> None: + session.add( + App( + id=_APP_ID, + tenant_id=_TENANT_ID, + name="Session App", + description="", + mode=AppMode.CHAT, + icon_type=None, + icon=None, + icon_background=None, + enable_site=enable_site, + enable_api=True, + ) + ) + session.add( + Site( + app_id=_APP_ID, + code="site-code", + title="Test Site", + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.UUID, + ) + ) + session.add( + EndUser( + id=_END_USER_ID, + tenant_id=_TENANT_ID, + app_id=_APP_ID, + type=EndUserType.BROWSER, + session_id="browser-session", + ) + ) + session.commit() def test_find_app_id_by_code_returns_matching_site_app(sqlite_session_factory: sessionmaker[Session]) -> None: @@ -37,6 +78,108 @@ def test_find_app_id_by_code_returns_none_for_missing_code( assert repository.find_app_id_by_code("missing-code") is None +def test_find_active_session_returns_framework_neutral_record( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_webapp_session(sqlite_session) + repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory) + + record = repository.find_active_session( + app_id=_APP_ID, + app_code="site-code", + end_user_id=_END_USER_ID, + ) + + assert record is not None + assert record.end_user_session_id == "browser-session" + + +def test_find_active_session_rejects_disabled_app( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + _persist_webapp_session(sqlite_session, enable_site=False) + repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory) + + assert ( + repository.find_active_session( + app_id=_APP_ID, + app_code="site-code", + end_user_id=_END_USER_ID, + ) + is None + ) + + +@pytest.mark.parametrize( + ("site_app_id", "end_user_app_id", "end_user_tenant_id"), + [ + pytest.param(_OTHER_APP_ID, _APP_ID, _TENANT_ID, id="site-app-mismatch"), + pytest.param(_APP_ID, _OTHER_APP_ID, _TENANT_ID, id="end-user-app-mismatch"), + pytest.param(_APP_ID, _APP_ID, _OTHER_TENANT_ID, id="end-user-tenant-mismatch"), + ], +) +def test_find_active_session_rejects_mismatched_owners( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + site_app_id: str, + end_user_app_id: str, + end_user_tenant_id: str, +) -> None: + _persist_webapp_session(sqlite_session) + with sqlite_session_factory.begin() as session: + session.add( + App( + id=_OTHER_APP_ID, + tenant_id=_TENANT_ID, + name="Other App", + mode=AppMode.CHAT, + enable_site=True, + enable_api=True, + ) + ) + site = session.scalar(select(Site).where(Site.code == "site-code")) + end_user = session.get(EndUser, _END_USER_ID) + assert site is not None + assert end_user is not None + site.app_id = site_app_id + end_user.app_id = end_user_app_id + end_user.tenant_id = end_user_tenant_id + + repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory) + + assert ( + repository.find_active_session( + app_id=_APP_ID, + app_code="site-code", + end_user_id=_END_USER_ID, + ) + is None + ) + + +@pytest.mark.parametrize( + ("app_id", "app_code", "end_user_id"), + [ + pytest.param(_OTHER_APP_ID, "site-code", _END_USER_ID, id="missing-app"), + pytest.param(_APP_ID, "missing-code", _END_USER_ID, id="missing-site"), + pytest.param(_APP_ID, "site-code", "66666666-6666-6666-6666-666666666666", id="missing-end-user"), + ], +) +def test_find_active_session_rejects_missing_records( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + app_id: str, + app_code: str, + end_user_id: str, +) -> None: + _persist_webapp_session(sqlite_session) + repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory) + + assert repository.find_active_session(app_id=app_id, app_code=app_code, end_user_id=end_user_id) is None + + def test_find_app_id_by_code_maps_database_failures_to_unavailable() -> None: database_error = OperationalError("select", {}, RuntimeError("connection failed")) session = MagicMock() diff --git a/api/tests/unit_tests/services/test_account_oauth_service.py b/api/tests/unit_tests/services/test_account_oauth_service.py index 12948bc4b55..9e1a3534702 100644 --- a/api/tests/unit_tests/services/test_account_oauth_service.py +++ b/api/tests/unit_tests/services/test_account_oauth_service.py @@ -98,6 +98,9 @@ class FakeAccounts: def get_credentials(self, account_id: str) -> NoReturn: raise AssertionError(account_id) + def find_for_authentication(self, email: str) -> NoReturn: + raise AssertionError(email) + def update_profile(self, account_id: str, changes: object) -> NoReturn: raise AssertionError((account_id, changes)) diff --git a/api/tests/unit_tests/services/test_web_authentication_adapters.py b/api/tests/unit_tests/services/test_web_authentication_adapters.py new file mode 100644 index 00000000000..f5570bfb6aa --- /dev/null +++ b/api/tests/unit_tests/services/test_web_authentication_adapters.py @@ -0,0 +1,539 @@ +"""Unit tests for Web authentication infrastructure adapters.""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from unittest.mock import MagicMock, call, patch + +import pytest + +from enums import WebAppAccessMode +from services.entities.auth_audit_entities import LoginFailureReason +from services.entities.authentication_entities import WebAppSessionRecord +from services.web_authentication_adapters import ( + AccountServiceWebAuthenticationSecurityGateway, + LoggingWebAuthenticationAuditGateway, + PassportWebAppSessionGateway, + TokenManagerWebAuthenticationGateway, +) +from services.web_authentication_service import WebEmailDeliveryRateLimitError + + +def token_gateway(*, limited: bool = False) -> tuple[TokenManagerWebAuthenticationGateway, MagicMock]: + limiter = MagicMock() + limiter.is_rate_limited.return_value = limited + limiter.time_window = 600 + return ( + TokenManagerWebAuthenticationGateway( + reset_password_rate_limiter=limiter, + access_token_expire_minutes=10, + ), + limiter, + ) + + +def test_issue_access_token_builds_web_login_claims() -> None: + gateway, _ = token_gateway() + issued_at = datetime(2026, 9, 23, 12, 0, tzinfo=UTC) + + with ( + patch("services.web_authentication_adapters.datetime") as clock, + patch("services.web_authentication_adapters.PassportService") as passport_service, + ): + clock.now.return_value = issued_at + passport_service.return_value.issue.return_value = "access-token" + + result = gateway.issue_access_token(account_id="account-1", email="User@Example.com") + + assert result == "access-token" + claims = passport_service.return_value.issue.call_args.args[0] + assert claims == { + "sub": "Web API Passport", + "user_id": "account-1", + "session_id": "User@Example.com", + "token_source": "webapp_login_token", + "auth_type": "internal", + "exp": claims["exp"], + } + assert claims["exp"] == int(issued_at.timestamp()) + 600 + + +def test_verify_access_token_rejects_missing_token_without_decoding() -> None: + gateway, _ = token_gateway() + + with patch("services.web_authentication_adapters.PassportService") as passport_service: + assert gateway.verify_access_token(None) is False + + passport_service.assert_not_called() + + +def test_verify_access_token_accepts_valid_passport() -> None: + gateway, _ = token_gateway() + + with patch("services.web_authentication_adapters.PassportService") as passport_service: + assert gateway.verify_access_token("signed-token") is True + + passport_service.return_value.verify.assert_called_once_with(token="signed-token") + + +def test_verify_access_token_rejects_invalid_passport() -> None: + gateway, _ = token_gateway() + + with patch("services.web_authentication_adapters.PassportService") as passport_service: + passport_service.return_value.verify.side_effect = ValueError("bad signature") + + assert gateway.verify_access_token("signed-token") is False + + +def test_send_email_login_code_persists_challenge_and_queues_mail() -> None: + gateway, _ = token_gateway() + + with ( + patch.object(gateway, "_generate_code", return_value="123456"), + patch("services.web_authentication_adapters.TokenManager.generate_token", return_value="challenge") as generate, + patch("services.web_authentication_adapters.send_email_code_login_mail_task.delay") as send_mail, + ): + result = gateway.send_email_login_code( + account_id="account-1", + email="user@example.com", + language="en-US", + ) + + assert result == "challenge" + generate.assert_called_once_with( + account_id="account-1", + email="user@example.com", + token_type="email_code_login", + additional_data={"code": "123456"}, + ) + send_mail.assert_called_once_with(language="en-US", to="user@example.com", code="123456") + + +def test_send_reset_password_code_rejects_rate_limited_email() -> None: + gateway, limiter = token_gateway(limited=True) + + with ( + patch("services.web_authentication_adapters.TokenManager.generate_token") as generate, + patch("services.web_authentication_adapters.send_reset_password_mail_task.delay") as send_mail, + pytest.raises(WebEmailDeliveryRateLimitError) as exc_info, + ): + gateway.send_reset_password_code(account_id="account-1", email="user@example.com", language="en-US") + + assert exc_info.value.flow == "reset_password" + assert exc_info.value.retry_after_minutes == 10 + generate.assert_not_called() + send_mail.assert_not_called() + limiter.increment_rate_limit.assert_not_called() + + +def test_send_reset_password_code_persists_challenge_queues_mail_and_increments_limit() -> None: + gateway, limiter = token_gateway() + + with ( + patch.object(gateway, "_generate_code", return_value="654321"), + patch("services.web_authentication_adapters.TokenManager.generate_token", return_value="challenge") as generate, + patch("services.web_authentication_adapters.send_reset_password_mail_task.delay") as send_mail, + ): + result = gateway.send_reset_password_code( + account_id="account-1", + email="user@example.com", + language="zh-Hans", + ) + + assert result == "challenge" + generate.assert_called_once_with( + account_id="account-1", + email="user@example.com", + token_type="reset_password", + additional_data={"code": "654321"}, + ) + send_mail.assert_called_once_with(language="zh-Hans", to="user@example.com", code="654321") + limiter.increment_rate_limit.assert_called_once_with("user@example.com") + + +def test_token_lookup_returns_none_when_token_manager_has_no_data() -> None: + gateway, _ = token_gateway() + + with patch("services.web_authentication_adapters.TokenManager.get_token_data", return_value=None) as get_data: + assert gateway.get_email_login_token("missing") is None + + get_data.assert_called_once_with("missing", "email_code_login") + + +@pytest.mark.parametrize( + ("data", "expected"), + [ + pytest.param( + {"email": "user@example.com", "code": "123456", "phase": "reset"}, + ("user@example.com", "123456", "reset"), + id="string-fields", + ), + pytest.param( + {"email": 1, "code": object(), "phase": False}, + (None, None, None), + id="invalid-field-types", + ), + ], +) +def test_reset_token_lookup_filters_untrusted_field_types( + data: dict[str, object], + expected: tuple[str | None, str | None, str | None], +) -> None: + gateway, _ = token_gateway() + + with patch("services.web_authentication_adapters.TokenManager.get_token_data", return_value=data): + result = gateway.get_reset_password_token("challenge") + + assert result is not None + assert (result.email, result.code, result.phase) == expected + + +def test_replace_and_revoke_tokens_use_their_expected_token_types() -> None: + gateway, _ = token_gateway() + + with ( + patch( + "services.web_authentication_adapters.TokenManager.generate_token", + return_value="replacement", + ) as generate, + patch("services.web_authentication_adapters.TokenManager.revoke_token") as revoke, + ): + result = gateway.replace_reset_password_token(email="user@example.com", code="123456") + gateway.revoke_email_login_token("email-token") + gateway.revoke_reset_password_token("reset-token") + + assert result == "replacement" + generate.assert_called_once_with( + email="user@example.com", + token_type="reset_password", + additional_data={"code": "123456", "phase": "reset"}, + ) + assert revoke.call_args_list == [ + call("email-token", "email_code_login"), + call("reset-token", "reset_password"), + ] + + +def test_generate_code_uses_six_random_digits() -> None: + with patch("services.web_authentication_adapters.secrets.randbelow", side_effect=[1, 2, 3, 4, 5, 6]) as randbelow: + assert TokenManagerWebAuthenticationGateway._generate_code() == "123456" + + assert randbelow.call_count == 6 + randbelow.assert_called_with(10) + + +def test_security_gateway_delegates_rate_limits_to_account_service() -> None: + account_service = MagicMock() + account_service.is_email_send_ip_limit.return_value = 1 + account_service.is_forgot_password_error_rate_limit.return_value = 0 + gateway = AccountServiceWebAuthenticationSecurityGateway(account_service=account_service) + + assert gateway.is_email_send_ip_limited("203.0.113.1") is True + assert gateway.is_password_reset_verification_limited("user@example.com") is False + gateway.record_password_reset_verification_failure("user@example.com") + gateway.reset_password_reset_verification_failures("user@example.com") + gateway.reset_login_failures("user@example.com") + + account_service.is_email_send_ip_limit.assert_called_once_with("203.0.113.1") + account_service.is_forgot_password_error_rate_limit.assert_called_once_with("user@example.com") + account_service.add_forgot_password_error_rate_limit.assert_called_once_with("user@example.com") + account_service.reset_forgot_password_error_rate_limit.assert_called_once_with("user@example.com") + account_service.reset_login_error_rate_limit.assert_called_once_with("user@example.com") + + +@dataclass +class SessionQueryFake: + session: WebAppSessionRecord | None = WebAppSessionRecord(end_user_session_id="session-1") + calls: list[tuple[str, str, str]] = field(default_factory=list) + + def find_active_session(self, *, app_id: str, app_code: str, end_user_id: str) -> WebAppSessionRecord | None: + self.calls.append((app_id, app_code, end_user_id)) + return self.session + + +@dataclass +class AppAccessFake: + access_mode: WebAppAccessMode = WebAppAccessMode.PUBLIC + permission_required: bool = False + user_allowed: bool = True + checked_users: list[tuple[str, str]] = field(default_factory=list) + + def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode: + assert app_id == "app-1" + assert app_code is None + return self.access_mode + + def requires_permission_check(self, app_id: str) -> bool: + assert app_id == "app-1" + return self.permission_required + + def is_user_allowed(self, *, user_id: str, app_id: str) -> bool: + self.checked_users.append((user_id, app_id)) + return self.user_allowed + + def find_app_id_by_code(self, app_code: str) -> str | None: + raise AssertionError(f"unused in session adapter: {app_code}") + + +_DEFAULT_SESSION = WebAppSessionRecord(end_user_session_id="session-1") + + +def passport_session_gateway( + *, + session: WebAppSessionRecord | None = _DEFAULT_SESSION, + access_mode: WebAppAccessMode = WebAppAccessMode.PUBLIC, + permission_required: bool = False, + user_allowed: bool = True, +) -> tuple[PassportWebAppSessionGateway, SessionQueryFake, AppAccessFake]: + sessions = SessionQueryFake(session=session) + access = AppAccessFake( + access_mode=access_mode, + permission_required=permission_required, + user_allowed=user_allowed, + ) + return PassportWebAppSessionGateway(sessions=sessions, app_access=access), sessions, access + + +def valid_session_claims(**overrides: object) -> dict[str, object]: + claims: dict[str, object] = { + "app_code": "site-code", + "app_id": "app-1", + "end_user_id": "end-user-1", + "token_source": "api", + } + claims.update(overrides) + return claims + + +def test_session_verification_rejects_missing_token_without_decoding() -> None: + gateway, _, _ = passport_session_gateway() + + with patch("services.web_authentication_adapters.PassportService") as passport_service: + assert gateway.verify(token=None, app_code="site-code", user_id=None) is False + + passport_service.assert_not_called() + + +def test_session_verification_queries_active_session_and_accepts_public_app() -> None: + gateway, sessions, _ = passport_session_gateway() + + with patch("services.web_authentication_adapters.PassportService") as passport_service: + passport_service.return_value.verify.return_value = valid_session_claims() + + result = gateway.verify(token="passport", app_code="site-code", user_id="session-1") + + assert result is True + assert sessions.calls == [("app-1", "site-code", "end-user-1")] + + +def test_session_verification_rejects_token_for_different_app_code() -> None: + gateway, sessions, _ = passport_session_gateway() + + with patch("services.web_authentication_adapters.PassportService") as passport_service: + passport_service.return_value.verify.return_value = valid_session_claims() + + assert gateway.verify(token="passport", app_code="other-site-code", user_id="session-1") is False + + assert sessions.calls == [] + + +@pytest.mark.parametrize( + "claims", + [ + pytest.param(valid_session_claims(app_code=1), id="app-code"), + pytest.param(valid_session_claims(app_id=1), id="app-id"), + pytest.param(valid_session_claims(end_user_id=1), id="end-user-id"), + ], +) +def test_session_verification_rejects_malformed_identity_claims(claims: dict[str, object]) -> None: + gateway, sessions, _ = passport_session_gateway() + + with patch("services.web_authentication_adapters.PassportService") as passport_service: + passport_service.return_value.verify.return_value = claims + + assert gateway.verify(token="passport", app_code="site-code", user_id=None) is False + + assert sessions.calls == [] + + +@pytest.mark.parametrize( + ("session", "user_id"), + [ + pytest.param(None, None, id="no-active-session"), + pytest.param(WebAppSessionRecord(end_user_session_id="session-1"), "other-session", id="user-mismatch"), + ], +) +def test_session_verification_rejects_missing_or_mismatched_session( + session: WebAppSessionRecord | None, + user_id: str | None, +) -> None: + gateway, _, _ = passport_session_gateway(session=session) + + with patch("services.web_authentication_adapters.PassportService") as passport_service: + passport_service.return_value.verify.return_value = valid_session_claims() + + assert gateway.verify(token="passport", app_code="site-code", user_id=user_id) is False + + +def test_session_verification_treats_decoder_errors_as_invalid() -> None: + gateway, _, _ = passport_session_gateway() + + with patch("services.web_authentication_adapters.PassportService") as passport_service: + passport_service.return_value.verify.side_effect = ValueError("bad signature") + + assert gateway.verify(token="passport", app_code="site-code", user_id=None) is False + + +@pytest.mark.parametrize( + ("claims", "permission_required", "user_allowed"), + [ + pytest.param({"token_source": "api"}, False, True, id="wrong-source"), + pytest.param({"token_source": "webapp", "user_id": 1}, False, True, id="invalid-user-id"), + pytest.param( + {"token_source": "webapp", "user_id": "user-1"}, + True, + False, + id="permission-denied", + ), + pytest.param( + {"token_source": "webapp", "user_id": "user-1", "granted_at": "yesterday", "auth_type": "internal"}, + False, + True, + id="invalid-granted-at", + ), + pytest.param( + {"token_source": "webapp", "user_id": "user-1", "granted_at": 100}, + False, + True, + id="missing-auth-type", + ), + pytest.param( + {"token_source": "webapp", "user_id": "user-1", "granted_at": 100, "auth_type": "unknown"}, + False, + True, + id="unknown-auth-type", + ), + ], +) +def test_private_app_claims_reject_invalid_authorization( + claims: dict[str, object], + permission_required: bool, + user_allowed: bool, +) -> None: + gateway, _, access = passport_session_gateway( + access_mode=WebAppAccessMode.PRIVATE, + permission_required=permission_required, + user_allowed=user_allowed, + ) + + assert gateway._verify_webapp_auth_claims(claims, "app-1") is False + if permission_required and isinstance(claims.get("user_id"), str): + assert access.checked_users == [("user-1", "app-1")] + + +@pytest.mark.parametrize( + ("access_mode", "auth_type", "settings_method"), + [ + pytest.param(WebAppAccessMode.SSO_VERIFIED, "external", "get_app_sso_settings_last_update_time", id="external"), + pytest.param( + WebAppAccessMode.PRIVATE, "internal", "get_workspace_sso_settings_last_update_time", id="internal" + ), + pytest.param( + WebAppAccessMode.PRIVATE_ALL, + "internal", + "get_workspace_sso_settings_last_update_time", + id="internal-all", + ), + ], +) +@pytest.mark.parametrize("granted_at", [99.0, 100.0], ids=["stale", "current"]) +def test_private_app_claims_compare_grant_with_sso_settings_update( + access_mode: WebAppAccessMode, + auth_type: str, + settings_method: str, + granted_at: float, +) -> None: + gateway, _, _ = passport_session_gateway(access_mode=access_mode) + claims = { + "token_source": "webapp", + "user_id": "user-1", + "granted_at": granted_at, + "auth_type": auth_type, + } + + with patch( + f"services.web_authentication_adapters.EnterpriseService.{settings_method}", + return_value=datetime.fromtimestamp(100, tz=UTC), + ): + result = gateway._verify_webapp_auth_claims(claims, "app-1") + + assert result is (granted_at >= 100) + + +@pytest.mark.parametrize( + ("access_mode", "auth_type", "expected"), + [ + pytest.param(WebAppAccessMode.PRIVATE, "internal", True, id="private-internal"), + pytest.param(WebAppAccessMode.PRIVATE, "external", False, id="private-external"), + pytest.param(WebAppAccessMode.PRIVATE_ALL, "internal", True, id="private-all-internal"), + pytest.param(WebAppAccessMode.PRIVATE_ALL, "external", False, id="private-all-external"), + pytest.param(WebAppAccessMode.SSO_VERIFIED, "external", True, id="sso-external"), + pytest.param(WebAppAccessMode.SSO_VERIFIED, "internal", False, id="sso-internal"), + ], +) +def test_session_verification_requires_auth_type_for_current_access_mode( + access_mode: WebAppAccessMode, + auth_type: str, + expected: bool, +) -> None: + gateway, _, _ = passport_session_gateway( + access_mode=access_mode, + permission_required=access_mode in {WebAppAccessMode.PRIVATE, WebAppAccessMode.PRIVATE_ALL}, + ) + claims = valid_session_claims(token_source="webapp", user_id="user-1", granted_at=100, auth_type=auth_type) + settings_updated_at = datetime.fromtimestamp(100, tz=UTC) + + with ( + patch("services.web_authentication_adapters.PassportService") as passport_service, + patch( + "services.web_authentication_adapters.EnterpriseService.get_app_sso_settings_last_update_time", + return_value=settings_updated_at, + ), + patch( + "services.web_authentication_adapters.EnterpriseService.get_workspace_sso_settings_last_update_time", + return_value=settings_updated_at, + ), + ): + passport_service.return_value.verify.return_value = claims + + assert gateway.verify(token="passport", app_code="site-code", user_id="session-1") is expected + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + pytest.param("api", True, id="ordinary-session"), + pytest.param("webapp", False, id="stale-private-session"), + ], +) +def test_public_app_claims_reject_private_webapp_session(source: str, expected: bool) -> None: + gateway, _, _ = passport_session_gateway(access_mode=WebAppAccessMode.PUBLIC) + + assert gateway._verify_webapp_auth_claims({"token_source": source}, "app-1") is expected + + +def test_login_failure_audit_writes_structured_warning() -> None: + logger = MagicMock() + gateway = LoggingWebAuthenticationAuditGateway(logger=logger) + + gateway.login_failed( + email="user@example.com", + reason=LoginFailureReason.INVALID_CREDENTIALS, + ip_address="203.0.113.1", + ) + + logger.warning.assert_called_once_with( + "Web login failed: email=%s reason=%s ip_address=%s", + "user@example.com", + LoginFailureReason.INVALID_CREDENTIALS, + "203.0.113.1", + ) diff --git a/api/tests/unit_tests/services/test_web_authentication_service.py b/api/tests/unit_tests/services/test_web_authentication_service.py new file mode 100644 index 00000000000..64d193b2477 --- /dev/null +++ b/api/tests/unit_tests/services/test_web_authentication_service.py @@ -0,0 +1,730 @@ +"""Unit tests for the framework-neutral Web authentication application service.""" + +from dataclasses import dataclass, field +from datetime import datetime + +import pytest + +from enums import WebAppAccessMode +from machinery.context import RequestContext +from services.entities.account_entities import AccountAuthenticationSnapshot, AccountPasswordDigest, AccountSnapshot +from services.entities.auth_audit_entities import LoginFailureReason +from services.entities.authentication_entities import StoredAuthenticationToken +from services.web_authentication_service import ( + WebAccountBannedError, + WebAuthenticationFailedError, + WebAuthenticationService, + WebEmailSendIPLimitedError, + WebInvalidCodeError, + WebInvalidEmailError, + WebInvalidTokenError, + WebPasswordMismatchError, + WebPasswordResetVerificationLimitedError, +) +from services.webapp_access_query_service import WebAppAccessAppNotFoundError + + +@dataclass +class AccountRepositoryFake: + accounts: dict[str, AccountAuthenticationSnapshot] + updated: tuple[str, AccountPasswordDigest] | None = None + update_succeeds: bool = True + + def find_for_authentication(self, email: str) -> AccountAuthenticationSnapshot | None: + return self.accounts.get(email) or self.accounts.get(email.lower()) + + def update_password(self, account_id: str, password: AccountPasswordDigest) -> AccountSnapshot | None: + self.updated = (account_id, password) + if not self.update_succeeds: + return None + account = next((account for account in self.accounts.values() if account.id == account_id), None) + if account is None: + return None + return AccountSnapshot( + id=account.id, + name="Account", + email=account.email, + avatar=None, + is_password_set=True, + interface_language="en-US", + interface_theme="light", + timezone="UTC", + last_login_at=None, + last_login_ip=None, + status=account.status, + initialized_at=None, + created_at=datetime(2026, 1, 1), + ) + + +class PasswordHasherFake: + def verify(self, password: str, *, password_hash: str, password_salt: str) -> bool: + return (password, password_hash, password_salt) == ("Valid1234", "hash", "salt") + + def hash(self, password: str) -> AccountPasswordDigest: + return AccountPasswordDigest(password_hash=f"hashed:{password}", password_salt="new-salt") + + +@dataclass +class TokenGatewayFake: + email_login_token: StoredAuthenticationToken | None = None + reset_token: StoredAuthenticationToken | None = None + revoked: list[tuple[str, str]] = field(default_factory=list) + + def issue_access_token(self, *, account_id: str, email: str) -> str: + return f"access:{account_id}:{email}" + + def verify_access_token(self, token: str | None) -> bool: + return token == "valid-access" + + def send_email_login_code(self, *, account_id: str, email: str, language: str) -> str: + return f"email:{account_id}:{email}:{language}" + + def get_email_login_token(self, token: str) -> StoredAuthenticationToken | None: + del token + return self.email_login_token + + def revoke_email_login_token(self, token: str) -> None: + self.revoked.append(("email", token)) + + def send_reset_password_code(self, *, account_id: str, email: str, language: str) -> str: + return f"reset:{account_id}:{email}:{language}" + + def get_reset_password_token(self, token: str) -> StoredAuthenticationToken | None: + del token + return self.reset_token + + def replace_reset_password_token(self, *, email: str, code: str) -> str: + return f"replacement:{email}:{code}" + + def revoke_reset_password_token(self, token: str) -> None: + self.revoked.append(("reset", token)) + + +@dataclass +class SecurityGatewayFake: + ip_limited: bool = False + reset_limited: bool = False + reset_failures: list[str] = field(default_factory=list) + reset_cleared: list[str] = field(default_factory=list) + login_cleared: list[str] = field(default_factory=list) + + def is_email_send_ip_limited(self, ip_address: str) -> bool: + del ip_address + return self.ip_limited + + def is_password_reset_verification_limited(self, email: str) -> bool: + del email + return self.reset_limited + + def record_password_reset_verification_failure(self, email: str) -> None: + self.reset_failures.append(email) + + def reset_password_reset_verification_failures(self, email: str) -> None: + self.reset_cleared.append(email) + + def reset_login_failures(self, email: str) -> None: + self.login_cleared.append(email) + + +@dataclass +class AppAccessGatewayFake: + permission_required: bool = True + + def find_app_id_by_code(self, app_code: str) -> str | None: + return "app-1" if app_code == "site-code" else None + + def requires_permission_check(self, app_id: str) -> bool: + del app_id + return self.permission_required + + def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode: + raise AssertionError(f"unused in authentication service: {app_id}, {app_code}") + + def is_user_allowed(self, *, user_id: str, app_id: str) -> bool: + del user_id, app_id + return True + + +@dataclass +class AppSessionGatewayFake: + valid: bool = False + + def verify(self, *, token: str | None, app_code: str, user_id: str | None) -> bool: + del app_code, user_id + return self.valid and token == "valid-passport" + + +@dataclass +class AuditGatewayFake: + failures: list[tuple[str, LoginFailureReason, str]] = field(default_factory=list) + + def login_failed(self, *, email: str, reason: LoginFailureReason, ip_address: str) -> None: + self.failures.append((email, reason, ip_address)) + + +@dataclass +class ServiceFixture: + service: WebAuthenticationService + accounts: AccountRepositoryFake + tokens: TokenGatewayFake + security: SecurityGatewayFake + app_access: AppAccessGatewayFake + app_sessions: AppSessionGatewayFake + audit: AuditGatewayFake + + +@pytest.fixture +def service_fixture() -> ServiceFixture: + account = AccountAuthenticationSnapshot( + id="account-1", + email="user@example.com", + status="active", + password_hash="hash", + password_salt="salt", + ) + accounts = AccountRepositoryFake({"user@example.com": account}) + tokens = TokenGatewayFake() + security = SecurityGatewayFake() + app_access = AppAccessGatewayFake() + app_sessions = AppSessionGatewayFake() + audit = AuditGatewayFake() + service = WebAuthenticationService( + accounts=accounts, + passwords=PasswordHasherFake(), + tokens=tokens, + security=security, + app_access=app_access, + app_sessions=app_sessions, + audit=audit, + private_app_access_enabled=True, + ) + return ServiceFixture(service, accounts, tokens, security, app_access, app_sessions, audit) + + +def request_context() -> RequestContext: + return RequestContext("request-1", "trace-1", "", "", "127.0.0.1") + + +def test_password_login_authenticates_and_issues_token(service_fixture: ServiceFixture) -> None: + token = service_fixture.service.login_with_password( + request_context(), + email="user@example.com", + password="Valid1234", + ) + + assert token == "access:account-1:user@example.com" + assert service_fixture.audit.failures == [] + + +@pytest.mark.parametrize( + ("email", "password", "reason"), + [ + pytest.param("missing@example.com", "Valid1234", LoginFailureReason.ACCOUNT_NOT_FOUND, id="missing"), + pytest.param("user@example.com", "wrong", LoginFailureReason.INVALID_CREDENTIALS, id="password"), + ], +) +def test_password_login_audits_rejected_credentials( + service_fixture: ServiceFixture, + email: str, + password: str, + reason: LoginFailureReason, +) -> None: + with pytest.raises(WebAuthenticationFailedError): + service_fixture.service.login_with_password(request_context(), email=email, password=password) + + assert service_fixture.audit.failures == [(email, reason, "127.0.0.1")] + + +def test_password_login_rejects_banned_account(service_fixture: ServiceFixture) -> None: + account = service_fixture.accounts.accounts["user@example.com"] + service_fixture.accounts.accounts["user@example.com"] = AccountAuthenticationSnapshot( + id=account.id, + email=account.email, + status="banned", + password_hash=account.password_hash, + password_salt=account.password_salt, + ) + + with pytest.raises(WebAccountBannedError): + service_fixture.service.login_with_password( + request_context(), + email="user@example.com", + password="Valid1234", + ) + + assert service_fixture.audit.failures[0][1] == LoginFailureReason.ACCOUNT_BANNED + + +def test_email_code_login_verifies_reissues_and_clears_failures(service_fixture: ServiceFixture) -> None: + service_fixture.tokens.email_login_token = StoredAuthenticationToken( + email="User@Example.com", + code="123456", + ) + + token = service_fixture.service.login_with_email_code( + request_context(), + email="user@example.com", + code="123456", + token="challenge", + ) + + assert token == "access:account-1:user@example.com" + assert service_fixture.tokens.revoked == [("email", "challenge")] + assert service_fixture.security.login_cleared == ["user@example.com"] + + +def test_email_code_login_audits_invalid_code(service_fixture: ServiceFixture) -> None: + service_fixture.tokens.email_login_token = StoredAuthenticationToken(email="user@example.com", code="123456") + + with pytest.raises(WebInvalidCodeError): + service_fixture.service.login_with_email_code( + request_context(), + email="user@example.com", + code="wrong", + token="challenge", + ) + + assert service_fixture.audit.failures[0][1] == LoginFailureReason.INVALID_EMAIL_CODE + + +def test_forgot_password_send_applies_ip_limit(service_fixture: ServiceFixture) -> None: + service_fixture.security.ip_limited = True + + with pytest.raises(WebEmailSendIPLimitedError): + service_fixture.service.send_reset_password_email( + request_context(), + email="user@example.com", + language=None, + ) + + +def test_reset_verification_rotates_token_and_clears_failures(service_fixture: ServiceFixture) -> None: + service_fixture.tokens.reset_token = StoredAuthenticationToken(email="User@Example.com", code="1234") + + token = service_fixture.service.verify_reset_password_code( + email="user@example.com", + code="1234", + token="verification-token", + ) + + assert token == "replacement:User@Example.com:1234" + assert service_fixture.tokens.revoked == [("reset", "verification-token")] + assert service_fixture.security.reset_cleared == ["user@example.com"] + + +def test_password_reset_updates_through_repository(service_fixture: ServiceFixture) -> None: + service_fixture.tokens.reset_token = StoredAuthenticationToken( + email="user@example.com", + code="1234", + phase="reset", + ) + + service_fixture.service.reset_password( + token="reset-token", + new_password="NewValid123!", + password_confirmation="NewValid123!", + ) + + assert service_fixture.tokens.revoked == [("reset", "reset-token")] + assert service_fixture.accounts.updated == ( + "account-1", + AccountPasswordDigest(password_hash="hashed:NewValid123!", password_salt="new-salt"), + ) + + +def test_password_reset_rejects_mismatch_before_reading_token(service_fixture: ServiceFixture) -> None: + with pytest.raises(WebPasswordMismatchError): + service_fixture.service.reset_password( + token="reset-token", + new_password="one", + password_confirmation="two", + ) + + +def test_login_status_uses_access_and_app_session_gateways(service_fixture: ServiceFixture) -> None: + service_fixture.app_sessions.valid = True + + status = service_fixture.service.get_login_status( + app_code="site-code", + user_id="session-1", + access_token="valid-access", + app_session_token="valid-passport", + ) + + assert status.logged_in is True + assert status.app_logged_in is True + + +def test_login_status_without_app_code_uses_token_presence_check( + service_fixture: ServiceFixture, +) -> None: + status = service_fixture.service.get_login_status( + app_code=None, + user_id=None, + access_token="not-verified-here", + app_session_token=None, + ) + + assert status.logged_in is True + assert status.app_logged_in is False + + +def test_reset_rejects_token_outside_reset_phase(service_fixture: ServiceFixture) -> None: + service_fixture.tokens.reset_token = StoredAuthenticationToken(email="user@example.com", code="1234") + + with pytest.raises(WebInvalidTokenError): + service_fixture.service.reset_password( + token="verification-token", + new_password="NewValid123!", + password_confirmation="NewValid123!", + ) + + +@pytest.mark.parametrize( + ("password_hash", "password_salt"), + [ + pytest.param(None, "salt", id="missing-hash"), + pytest.param("hash", None, id="missing-salt"), + ], +) +def test_password_login_rejects_account_without_complete_password_digest( + service_fixture: ServiceFixture, + password_hash: str | None, + password_salt: str | None, +) -> None: + service_fixture.accounts.accounts["user@example.com"] = AccountAuthenticationSnapshot( + id="account-1", + email="user@example.com", + status="active", + password_hash=password_hash, + password_salt=password_salt, + ) + + with pytest.raises(WebAuthenticationFailedError): + service_fixture.service.login_with_password( + request_context(), + email="user@example.com", + password="Valid1234", + ) + + assert service_fixture.audit.failures == [("user@example.com", LoginFailureReason.INVALID_CREDENTIALS, "127.0.0.1")] + + +def test_login_status_rejects_unknown_app_code(service_fixture: ServiceFixture) -> None: + with pytest.raises(WebAppAccessAppNotFoundError, match="App with code missing-code not found"): + service_fixture.service.get_login_status( + app_code="missing-code", + user_id=None, + access_token=None, + app_session_token=None, + ) + + +@pytest.mark.parametrize( + ("private_access_enabled", "permission_required", "expected_logged_in"), + [ + pytest.param(False, True, True, id="private-access-disabled"), + pytest.param(True, False, True, id="permission-not-required"), + pytest.param(True, True, False, id="private-and-no-token"), + ], +) +def test_login_status_applies_public_access_policy( + service_fixture: ServiceFixture, + private_access_enabled: bool, + permission_required: bool, + expected_logged_in: bool, +) -> None: + service_fixture.app_access.permission_required = permission_required + service = WebAuthenticationService( + accounts=service_fixture.accounts, + passwords=PasswordHasherFake(), + tokens=service_fixture.tokens, + security=service_fixture.security, + app_access=service_fixture.app_access, + app_sessions=service_fixture.app_sessions, + audit=service_fixture.audit, + private_app_access_enabled=private_access_enabled, + ) + + status = service.get_login_status( + app_code="site-code", + user_id=None, + access_token=None, + app_session_token=None, + ) + + assert status.logged_in is expected_logged_in + assert status.app_logged_in is False + + +@pytest.mark.parametrize( + ("language", "expected_language"), + [ + pytest.param(None, "en-US", id="default"), + pytest.param("zh-Hans", "zh-Hans", id="simplified-chinese"), + ], +) +def test_send_email_login_code_uses_supported_language( + service_fixture: ServiceFixture, + language: str | None, + expected_language: str, +) -> None: + token = service_fixture.service.send_email_login_code(email="user@example.com", language=language) + + assert token == f"email:account-1:user@example.com:{expected_language}" + + +@pytest.mark.parametrize( + ("account_status", "error"), + [ + pytest.param(None, WebAuthenticationFailedError, id="missing-account"), + pytest.param("banned", WebAccountBannedError, id="banned-account"), + ], +) +def test_send_email_login_code_rejects_ineligible_account( + service_fixture: ServiceFixture, + account_status: str | None, + error: type[Exception], +) -> None: + if account_status is None: + service_fixture.accounts.accounts.clear() + else: + service_fixture.accounts.accounts["user@example.com"] = AccountAuthenticationSnapshot( + id="account-1", + email="user@example.com", + status=account_status, + password_hash="hash", + password_salt="salt", + ) + + with pytest.raises(error): + service_fixture.service.send_email_login_code(email="user@example.com", language=None) + + +@pytest.mark.parametrize( + ("token_data", "error", "reason"), + [ + pytest.param( + None, + WebInvalidTokenError, + LoginFailureReason.INVALID_EMAIL_CODE_TOKEN, + id="missing-token", + ), + pytest.param( + StoredAuthenticationToken(email=None, code="123456"), + WebInvalidEmailError, + LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH, + id="missing-email", + ), + pytest.param( + StoredAuthenticationToken(email="other@example.com", code="123456"), + WebInvalidEmailError, + LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH, + id="different-email", + ), + ], +) +def test_email_code_login_rejects_invalid_token_identity( + service_fixture: ServiceFixture, + token_data: StoredAuthenticationToken | None, + error: type[Exception], + reason: LoginFailureReason, +) -> None: + service_fixture.tokens.email_login_token = token_data + + with pytest.raises(error): + service_fixture.service.login_with_email_code( + request_context(), + email="User@Example.com", + code="123456", + token="challenge", + ) + + assert service_fixture.audit.failures == [("user@example.com", reason, "127.0.0.1")] + assert service_fixture.tokens.revoked == [] + + +def test_email_code_login_audits_account_removed_after_challenge_was_issued( + service_fixture: ServiceFixture, +) -> None: + service_fixture.tokens.email_login_token = StoredAuthenticationToken( + email="removed@example.com", + code="123456", + ) + + with pytest.raises(WebAuthenticationFailedError): + service_fixture.service.login_with_email_code( + request_context(), + email="removed@example.com", + code="123456", + token="challenge", + ) + + assert service_fixture.tokens.revoked == [("email", "challenge")] + assert service_fixture.audit.failures == [ + ("removed@example.com", LoginFailureReason.ACCOUNT_NOT_FOUND, "127.0.0.1") + ] + + +def test_email_code_login_rejects_account_banned_after_challenge_was_issued( + service_fixture: ServiceFixture, +) -> None: + account = service_fixture.accounts.accounts["user@example.com"] + service_fixture.accounts.accounts["user@example.com"] = AccountAuthenticationSnapshot( + id=account.id, + email=account.email, + status="banned", + password_hash=account.password_hash, + password_salt=account.password_salt, + ) + service_fixture.tokens.email_login_token = StoredAuthenticationToken(email=account.email, code="123456") + + with pytest.raises(WebAccountBannedError): + service_fixture.service.login_with_email_code( + request_context(), + email=account.email, + code="123456", + token="challenge", + ) + + assert service_fixture.tokens.revoked == [("email", "challenge")] + assert service_fixture.audit.failures[0][1] == LoginFailureReason.ACCOUNT_BANNED + + +def test_send_reset_password_email_issues_challenge_with_default_language( + service_fixture: ServiceFixture, +) -> None: + token = service_fixture.service.send_reset_password_email( + request_context(), + email="user@example.com", + language=None, + ) + + assert token == "reset:account-1:user@example.com:en-US" + + +def test_send_reset_password_email_rejects_missing_account(service_fixture: ServiceFixture) -> None: + service_fixture.accounts.accounts.clear() + + with pytest.raises(WebAuthenticationFailedError): + service_fixture.service.send_reset_password_email( + request_context(), + email="missing@example.com", + language=None, + ) + + +def test_reset_verification_rejects_rate_limited_email(service_fixture: ServiceFixture) -> None: + service_fixture.security.reset_limited = True + + with pytest.raises(WebPasswordResetVerificationLimitedError): + service_fixture.service.verify_reset_password_code( + email="User@Example.com", + code="123456", + token="challenge", + ) + + +def test_reset_verification_rejects_missing_token(service_fixture: ServiceFixture) -> None: + with pytest.raises(WebInvalidTokenError): + service_fixture.service.verify_reset_password_code( + email="user@example.com", + code="123456", + token="missing", + ) + + +@pytest.mark.parametrize( + "token_email", + [ + pytest.param(None, id="missing-email"), + pytest.param("other@example.com", id="different-email"), + ], +) +def test_reset_verification_rejects_token_for_another_email( + service_fixture: ServiceFixture, + token_email: str | None, +) -> None: + service_fixture.tokens.reset_token = StoredAuthenticationToken(email=token_email, code="123456") + + with pytest.raises(WebInvalidEmailError): + service_fixture.service.verify_reset_password_code( + email="user@example.com", + code="123456", + token="challenge", + ) + + +def test_reset_verification_records_incorrect_code(service_fixture: ServiceFixture) -> None: + service_fixture.tokens.reset_token = StoredAuthenticationToken(email="User@Example.com", code="123456") + + with pytest.raises(WebInvalidCodeError): + service_fixture.service.verify_reset_password_code( + email="user@example.com", + code="wrong", + token="challenge", + ) + + assert service_fixture.security.reset_failures == ["user@example.com"] + assert service_fixture.tokens.revoked == [] + + +def test_password_reset_rejects_missing_token(service_fixture: ServiceFixture) -> None: + with pytest.raises(WebInvalidTokenError): + service_fixture.service.reset_password( + token="missing", + new_password="NewValid123!", + password_confirmation="NewValid123!", + ) + + +def test_password_reset_rejects_token_without_email_after_revocation(service_fixture: ServiceFixture) -> None: + service_fixture.tokens.reset_token = StoredAuthenticationToken(email=None, code="123456", phase="reset") + + with pytest.raises(WebAuthenticationFailedError): + service_fixture.service.reset_password( + token="challenge", + new_password="NewValid123!", + password_confirmation="NewValid123!", + ) + + assert service_fixture.tokens.revoked == [("reset", "challenge")] + + +def test_password_reset_rejects_account_removed_after_verification(service_fixture: ServiceFixture) -> None: + service_fixture.tokens.reset_token = StoredAuthenticationToken( + email="removed@example.com", + code="123456", + phase="reset", + ) + + with pytest.raises(WebAuthenticationFailedError): + service_fixture.service.reset_password( + token="challenge", + new_password="NewValid123!", + password_confirmation="NewValid123!", + ) + + assert service_fixture.tokens.revoked == [("reset", "challenge")] + + +def test_password_reset_rejects_failed_repository_update(service_fixture: ServiceFixture) -> None: + service_fixture.tokens.reset_token = StoredAuthenticationToken( + email="user@example.com", + code="123456", + phase="reset", + ) + service_fixture.accounts.update_succeeds = False + + with pytest.raises(WebAuthenticationFailedError): + service_fixture.service.reset_password( + token="challenge", + new_password="NewValid123!", + password_confirmation="NewValid123!", + ) + + assert service_fixture.accounts.updated == ( + "account-1", + AccountPasswordDigest(password_hash="hashed:NewValid123!", password_salt="new-salt"), + ) diff --git a/api/tests/unit_tests/services/test_webapp_access_adapters.py b/api/tests/unit_tests/services/test_webapp_access_adapters.py new file mode 100644 index 00000000000..03c473d631e --- /dev/null +++ b/api/tests/unit_tests/services/test_webapp_access_adapters.py @@ -0,0 +1,104 @@ +import json +from dataclasses import dataclass, field + +import httpx +import pytest +from pydantic import ValidationError + +from enums import WebAppAccessMode +from services.enterprise.enterprise_service import WebAppSettings +from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPINotFoundError +from services.webapp_access_adapters import EnterpriseWebAppAccessPolicyGateway +from services.webapp_access_query_service import WebAppAccessUnavailableError + + +@dataclass +class EnterpriseWebAppAuthStub: + access_mode: str = WebAppAccessMode.PRIVATE.value + allowed: bool = True + access_mode_error: Exception | None = None + permission_error: Exception | None = None + access_mode_calls: list[str] = field(default_factory=list) + permission_calls: list[tuple[str, str]] = field(default_factory=list) + + def get_app_access_mode_by_id(self, app_id: str) -> WebAppSettings: + self.access_mode_calls.append(app_id) + if self.access_mode_error is not None: + raise self.access_mode_error + return WebAppSettings(accessMode=self.access_mode) + + def is_user_allowed_to_access_webapp(self, user_id: str, app_id: str) -> bool: + self.permission_calls.append((user_id, app_id)) + if self.permission_error is not None: + raise self.permission_error + return self.allowed + + +def test_get_access_mode_converts_enterprise_settings() -> None: + webapp_auth = EnterpriseWebAppAuthStub(access_mode=WebAppAccessMode.PRIVATE_ALL.value) + gateway = EnterpriseWebAppAccessPolicyGateway(webapp_auth=webapp_auth) + + assert gateway.get_access_mode("app-1") is WebAppAccessMode.PRIVATE_ALL + assert webapp_auth.access_mode_calls == ["app-1"] + + +@pytest.mark.parametrize( + "enterprise_error", + [ + pytest.param(EnterpriseAPINotFoundError(), id="not-found"), + pytest.param(EnterpriseAPIError(), id="api-error"), + pytest.param(httpx.ConnectError("connection failed"), id="transport"), + pytest.param(json.JSONDecodeError("invalid", "", 0), id="invalid-json"), + pytest.param(UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid"), id="invalid-encoding"), + pytest.param( + ValidationError.from_exception_data(WebAppSettings.__name__, []), + id="invalid-response", + ), + ], +) +def test_get_access_mode_maps_known_enterprise_errors(enterprise_error: Exception) -> None: + gateway = EnterpriseWebAppAccessPolicyGateway( + webapp_auth=EnterpriseWebAppAuthStub(access_mode_error=enterprise_error) + ) + + with pytest.raises(WebAppAccessUnavailableError) as raised: + gateway.get_access_mode("app-1") + + assert raised.value.__cause__ is enterprise_error + + +def test_get_access_mode_maps_unknown_mode_to_unavailable() -> None: + gateway = EnterpriseWebAppAccessPolicyGateway(webapp_auth=EnterpriseWebAppAuthStub(access_mode="invalid")) + + with pytest.raises(WebAppAccessUnavailableError) as raised: + gateway.get_access_mode("app-1") + + assert isinstance(raised.value.__cause__, ValueError) + + +def test_get_access_mode_does_not_hide_unknown_errors() -> None: + failure = TypeError("adapter bug") + gateway = EnterpriseWebAppAccessPolicyGateway(webapp_auth=EnterpriseWebAppAuthStub(access_mode_error=failure)) + + with pytest.raises(TypeError) as raised: + gateway.get_access_mode("app-1") + + assert raised.value is failure + + +def test_is_user_allowed_delegates_to_enterprise_service() -> None: + webapp_auth = EnterpriseWebAppAuthStub(allowed=False) + gateway = EnterpriseWebAppAccessPolicyGateway(webapp_auth=webapp_auth) + + assert gateway.is_user_allowed(user_id="user-1", app_id="app-1") is False + assert webapp_auth.permission_calls == [("user-1", "app-1")] + + +def test_is_user_allowed_maps_connection_failure() -> None: + failure = httpx.ConnectError("connection failed") + gateway = EnterpriseWebAppAccessPolicyGateway(webapp_auth=EnterpriseWebAppAuthStub(permission_error=failure)) + + with pytest.raises(WebAppAccessUnavailableError) as raised: + gateway.is_user_allowed(user_id="user-1", app_id="app-1") + + assert raised.value.__cause__ is failure diff --git a/api/tests/unit_tests/services/test_webapp_access_query_service.py b/api/tests/unit_tests/services/test_webapp_access_query_service.py index 1f0839f0e37..d2e0167c151 100644 --- a/api/tests/unit_tests/services/test_webapp_access_query_service.py +++ b/api/tests/unit_tests/services/test_webapp_access_query_service.py @@ -1,6 +1,5 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass, field -from unittest.mock import MagicMock, create_autospec import pytest from sqlalchemy import Engine, event @@ -12,12 +11,50 @@ from models.model import Site from repositories.webapp_access_query_repository import WebAppAccessQueryRepository from services.webapp_access_query_service import ( WebAppAccessAppNotFoundError, - WebAppAccessQuery, WebAppAccessQueryService, WebAppAccessReferenceRequiredError, ) +@dataclass +class AccessQueryStub: + app_id: str | None = None + error: Exception | None = None + calls: list[str] = field(default_factory=list) + + def find_app_id_by_code(self, app_code: str) -> str | None: + self.calls.append(app_code) + if self.error is not None: + raise self.error + return self.app_id + + +@dataclass +class AccessPolicyStub: + access_mode: WebAppAccessMode = WebAppAccessMode.PRIVATE + allowed: bool = True + access_mode_error: Exception | None = None + access_mode_calls: list[str] = field(default_factory=list) + permission_calls: list[tuple[str, str]] = field(default_factory=list) + + def get_access_mode(self, app_id: str) -> WebAppAccessMode: + self.access_mode_calls.append(app_id) + if self.access_mode_error is not None: + raise self.access_mode_error + return self.access_mode + + def is_user_allowed(self, *, user_id: str, app_id: str) -> bool: + self.permission_calls.append((user_id, app_id)) + return self.allowed + + +@dataclass(frozen=True, slots=True) +class ServiceFixture: + service: WebAppAccessQueryService + access: AccessQueryStub + policy: AccessPolicyStub + + def _unexpected_access_modes(*, app_ids: Sequence[str]) -> Mapping[str, WebAppAccessMode]: raise AssertionError(f"Unexpected batch access mode query: {app_ids}") @@ -28,96 +65,88 @@ def _unexpected_user_permissions(*, user_id: str, app_ids: Sequence[str]) -> Map def _service( *, - access: MagicMock, + access: AccessQueryStub | None = None, enabled: bool = True, access_mode: WebAppAccessMode = WebAppAccessMode.PRIVATE, allowed: bool = True, -) -> tuple[WebAppAccessQueryService, MagicMock, MagicMock]: - access_mode_for_app = MagicMock(return_value=access_mode) - is_user_allowed_for_app = MagicMock(return_value=allowed) - return ( - WebAppAccessQueryService( +) -> ServiceFixture: + access = access or AccessQueryStub() + policy = AccessPolicyStub(access_mode=access_mode, allowed=allowed) + return ServiceFixture( + service=WebAppAccessQueryService( access=access, + policy=policy, webapp_auth_enabled=enabled, - access_mode_for_app=access_mode_for_app, - is_user_allowed_for_app=is_user_allowed_for_app, get_access_modes=_unexpected_access_modes, get_user_permissions=_unexpected_user_permissions, ), - access_mode_for_app, - is_user_allowed_for_app, + access=access, + policy=policy, ) def test_disabled_auth_returns_public_before_resolving_app() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - service, access_mode_for_app, _ = _service(access=access, enabled=False) + fixture = _service(enabled=False) - assert service.get_access_mode(app_id=None, app_code=None) is WebAppAccessMode.PUBLIC - access.find_app_id_by_code.assert_not_called() - access_mode_for_app.assert_not_called() + assert fixture.service.get_access_mode(app_id=None, app_code=None) is WebAppAccessMode.PUBLIC + assert fixture.access.calls == [] + assert fixture.policy.access_mode_calls == [] def test_enabled_auth_reads_access_mode_by_app_id() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - service, access_mode_for_app, _ = _service(access=access) + fixture = _service() - assert service.get_access_mode(app_id="app-1", app_code=None) is WebAppAccessMode.PRIVATE - access.find_app_id_by_code.assert_not_called() - access_mode_for_app.assert_called_once_with("app-1") + assert fixture.service.get_access_mode(app_id="app-1", app_code=None) is WebAppAccessMode.PRIVATE + assert fixture.access.calls == [] + assert fixture.policy.access_mode_calls == ["app-1"] def test_app_code_takes_precedence_over_app_id() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - access.find_app_id_by_code.return_value = "resolved-id" - service, access_mode_for_app, _ = _service(access=access, access_mode=WebAppAccessMode.SSO_VERIFIED) + fixture = _service( + access=AccessQueryStub(app_id="resolved-id"), + access_mode=WebAppAccessMode.SSO_VERIFIED, + ) - assert service.get_access_mode(app_id="ignored-id", app_code="code-1") is WebAppAccessMode.SSO_VERIFIED - access.find_app_id_by_code.assert_called_once_with("code-1") - access_mode_for_app.assert_called_once_with("resolved-id") + assert fixture.service.get_access_mode(app_id="ignored-id", app_code="code-1") is WebAppAccessMode.SSO_VERIFIED + assert fixture.access.calls == ["code-1"] + assert fixture.policy.access_mode_calls == ["resolved-id"] def test_missing_app_code_raises_not_found() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - access.find_app_id_by_code.return_value = None - service, access_mode_for_app, _ = _service(access=access) + fixture = _service() with pytest.raises(WebAppAccessAppNotFoundError): - service.get_access_mode(app_id="must-not-fallback", app_code="missing-code") + fixture.service.get_access_mode(app_id="must-not-fallback", app_code="missing-code") - access_mode_for_app.assert_not_called() + assert fixture.policy.access_mode_calls == [] def test_enabled_auth_requires_app_id_or_code() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - service, access_mode_for_app, _ = _service(access=access) + fixture = _service() with pytest.raises(WebAppAccessReferenceRequiredError, match="^appId or appCode must be provided$"): - service.get_access_mode(app_id=None, app_code=None) + fixture.service.get_access_mode(app_id=None, app_code=None) - access_mode_for_app.assert_not_called() + assert fixture.policy.access_mode_calls == [] def test_repository_failure_is_not_hidden() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) failure = TypeError("repository bug") - access.find_app_id_by_code.side_effect = failure - service, _, _ = _service(access=access) + fixture = _service(access=AccessQueryStub(error=failure)) with pytest.raises(TypeError) as raised: - service.get_access_mode(app_id=None, app_code="code-1") + fixture.service.get_access_mode(app_id=None, app_code="code-1") assert raised.value is failure def test_access_mode_failure_is_not_hidden() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - service, access_mode_for_app, _ = _service(access=access) + fixture = _service() failure = TypeError("adapter bug") - access_mode_for_app.side_effect = failure + fixture.policy.access_mode_error = failure with pytest.raises(TypeError) as raised: - service.get_access_mode(app_id="app-1", app_code=None) + fixture.service.get_access_mode(app_id="app-1", app_code=None) assert raised.value is failure @@ -132,39 +161,31 @@ def test_access_mode_failure_is_not_hidden() -> None: ], ) def test_requires_permission_check_for_private_modes(access_mode: WebAppAccessMode, expected: bool) -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - service, access_mode_for_app, _ = _service(access=access, access_mode=access_mode) + fixture = _service(access_mode=access_mode) - assert service.requires_permission_check("app-1") is expected - access_mode_for_app.assert_called_once_with("app-1") + assert fixture.service.requires_permission_check("app-1") is expected + assert fixture.policy.access_mode_calls == ["app-1"] def test_disabled_auth_still_reads_configured_mode_before_passport() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - service, access_mode_for_app, _ = _service( - access=access, - enabled=False, - access_mode=WebAppAccessMode.PRIVATE, - ) + fixture = _service(enabled=False, access_mode=WebAppAccessMode.PRIVATE) - assert service.requires_permission_check("app-1") is True - access_mode_for_app.assert_called_once_with("app-1") + assert fixture.service.requires_permission_check("app-1") is True + assert fixture.policy.access_mode_calls == ["app-1"] def test_disabled_auth_allows_after_passport_without_querying_user_permission() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - service, _, is_user_allowed_for_app = _service(access=access, enabled=False, allowed=False) + fixture = _service(enabled=False, allowed=False) - assert service.is_user_allowed(user_id="user-1", app_id="app-1") is True - is_user_allowed_for_app.assert_not_called() + assert fixture.service.is_user_allowed(user_id="user-1", app_id="app-1") is True + assert fixture.policy.permission_calls == [] def test_enabled_auth_delegates_user_permission() -> None: - access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True) - service, _, is_user_allowed_for_app = _service(access=access, allowed=False) + fixture = _service(allowed=False) - assert service.is_user_allowed(user_id="user-1", app_id="app-1") is False - is_user_allowed_for_app.assert_called_once_with("user-1", "app-1") + assert fixture.service.is_user_allowed(user_id="user-1", app_id="app-1") is False + assert fixture.policy.permission_calls == [("user-1", "app-1")] @pytest.mark.parametrize("missing", [False, True], ids=["found", "missing"]) @@ -191,19 +212,19 @@ def test_app_code_lookup_releases_database_before_access_policy(sqlite_engine: E def checkin(connection: object, *_args: object) -> None: connections.remove(connection) - def access_mode_for_app(resolved_app_id: str) -> WebAppAccessMode: - assert not connections - resolved.append(resolved_app_id) - return WebAppAccessMode.PRIVATE + class AccessPolicy: + def get_access_mode(self, app_id: str) -> WebAppAccessMode: + assert not connections + resolved.append(app_id) + return WebAppAccessMode.PRIVATE - def is_user_allowed_for_app(_user_id: str, _app_id: str) -> bool: - raise AssertionError("Resolving access mode must not query user permissions") + def is_user_allowed(self, *, user_id: str, app_id: str) -> bool: + raise AssertionError(f"Resolving access mode must not query user permissions: {user_id}, {app_id}") service = WebAppAccessQueryService( access=WebAppAccessQueryRepository(session_factory=factory), webapp_auth_enabled=True, - access_mode_for_app=access_mode_for_app, - is_user_allowed_for_app=is_user_allowed_for_app, + policy=AccessPolicy(), get_access_modes=_unexpected_access_modes, get_user_permissions=_unexpected_user_permissions, ) @@ -250,16 +271,15 @@ class _BatchQueries: def get_access_mode(self, app_id: str) -> WebAppAccessMode: raise AssertionError(f"Batch queries must not use single-app lookups: {app_id}") - def is_user_allowed(self, user_id: str, app_id: str) -> bool: + def is_user_allowed(self, *, user_id: str, app_id: str) -> bool: raise AssertionError(f"Batch queries must not use single-app permissions: {user_id}, {app_id}") def _batch_service(queries: _BatchQueries, *, enabled: bool = True) -> WebAppAccessQueryService: return WebAppAccessQueryService( access=queries, + policy=queries, webapp_auth_enabled=enabled, - access_mode_for_app=queries.get_access_mode, - is_user_allowed_for_app=queries.is_user_allowed, get_access_modes=queries.get_access_modes, get_user_permissions=queries.get_user_permissions, )