mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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:
1 parent
bf41e733ba
commit
6decc78fd5
28 files changed
+3179
-1657
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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,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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user