refactor(api): move web authentication into application services (#41318)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Byron.wangandautofix-ci[bot] authored and GitHub committed 2026-09-30 06:47:17 +00:00
1 parent bf41e733ba
commit 6decc78fd5
28 files changed
+3179 -1657

No files matched your search

+29
View File
@@ -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
+64
View File
@@ -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
+69 -111
View File
@@ -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")
+72 -114
View File
@@ -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),
)
+35 -27
View File
@@ -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,
+6
View File
@@ -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)
+23
View File
@@ -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()
@@ -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
+3
View File
@@ -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: ...
@@ -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
@@ -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
+243
View File
@@ -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,
)
+301
View File
@@ -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"
+48
View File
@@ -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
+18 -8
View File
@@ -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."""
+1 -93
View File
@@ -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
@@ -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)
+3 -4
View File
@@ -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,
),
@@ -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()
@@ -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")]
@@ -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
@@ -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],
@@ -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()
@@ -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))
@@ -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",
)
@@ -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"),
)
@@ -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
@@ -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,
)