mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 17:27:16 -05:00
fix: Login secret text transmission (#29659)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
@@ -22,7 +22,12 @@ from controllers.console.error import (
|
|||||||
NotAllowedCreateWorkspace,
|
NotAllowedCreateWorkspace,
|
||||||
WorkspacesLimitExceeded,
|
WorkspacesLimitExceeded,
|
||||||
)
|
)
|
||||||
from controllers.console.wraps import email_password_login_enabled, setup_required
|
from controllers.console.wraps import (
|
||||||
|
decrypt_code_field,
|
||||||
|
decrypt_password_field,
|
||||||
|
email_password_login_enabled,
|
||||||
|
setup_required,
|
||||||
|
)
|
||||||
from events.tenant_event import tenant_was_created
|
from events.tenant_event import tenant_was_created
|
||||||
from libs.helper import EmailStr, extract_remote_ip
|
from libs.helper import EmailStr, extract_remote_ip
|
||||||
from libs.login import current_account_with_tenant
|
from libs.login import current_account_with_tenant
|
||||||
@@ -79,6 +84,7 @@ class LoginApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@email_password_login_enabled
|
@email_password_login_enabled
|
||||||
@console_ns.expect(console_ns.models[LoginPayload.__name__])
|
@console_ns.expect(console_ns.models[LoginPayload.__name__])
|
||||||
|
@decrypt_password_field
|
||||||
def post(self):
|
def post(self):
|
||||||
"""Authenticate user and login."""
|
"""Authenticate user and login."""
|
||||||
args = LoginPayload.model_validate(console_ns.payload)
|
args = LoginPayload.model_validate(console_ns.payload)
|
||||||
@@ -218,6 +224,7 @@ class EmailCodeLoginSendEmailApi(Resource):
|
|||||||
class EmailCodeLoginApi(Resource):
|
class EmailCodeLoginApi(Resource):
|
||||||
@setup_required
|
@setup_required
|
||||||
@console_ns.expect(console_ns.models[EmailCodeLoginPayload.__name__])
|
@console_ns.expect(console_ns.models[EmailCodeLoginPayload.__name__])
|
||||||
|
@decrypt_code_field
|
||||||
def post(self):
|
def post(self):
|
||||||
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
|
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ from typing import ParamSpec, TypeVar
|
|||||||
from flask import abort, request
|
from flask import abort, request
|
||||||
|
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
|
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
|
||||||
from controllers.console.workspace.error import AccountNotInitializedError
|
from controllers.console.workspace.error import AccountNotInitializedError
|
||||||
from enums.cloud_plan import CloudPlan
|
from enums.cloud_plan import CloudPlan
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from extensions.ext_redis import redis_client
|
from extensions.ext_redis import redis_client
|
||||||
|
from libs.encryption import FieldEncryption
|
||||||
from libs.login import current_account_with_tenant
|
from libs.login import current_account_with_tenant
|
||||||
from models.account import AccountStatus
|
from models.account import AccountStatus
|
||||||
from models.dataset import RateLimitLog
|
from models.dataset import RateLimitLog
|
||||||
@@ -25,6 +27,14 @@ from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogo
|
|||||||
P = ParamSpec("P")
|
P = ParamSpec("P")
|
||||||
R = TypeVar("R")
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
# Field names for decryption
|
||||||
|
FIELD_NAME_PASSWORD = "password"
|
||||||
|
FIELD_NAME_CODE = "code"
|
||||||
|
|
||||||
|
# Error messages for decryption failures
|
||||||
|
ERROR_MSG_INVALID_ENCRYPTED_DATA = "Invalid encrypted data"
|
||||||
|
ERROR_MSG_INVALID_ENCRYPTED_CODE = "Invalid encrypted code"
|
||||||
|
|
||||||
|
|
||||||
def account_initialization_required(view: Callable[P, R]):
|
def account_initialization_required(view: Callable[P, R]):
|
||||||
@wraps(view)
|
@wraps(view)
|
||||||
@@ -419,3 +429,75 @@ def annotation_import_concurrency_limit(view: Callable[P, R]):
|
|||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_field(field_name: str, error_class: type[Exception], error_message: str) -> None:
|
||||||
|
"""
|
||||||
|
Helper to decode a Base64 encoded field in the request payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_name: Name of the field to decode
|
||||||
|
error_class: Exception class to raise on decoding failure
|
||||||
|
error_message: Error message to include in the exception
|
||||||
|
"""
|
||||||
|
if not request or not request.is_json:
|
||||||
|
return
|
||||||
|
# Get the payload dict - it's cached and mutable
|
||||||
|
payload = request.get_json()
|
||||||
|
if not payload or field_name not in payload:
|
||||||
|
return
|
||||||
|
encoded_value = payload[field_name]
|
||||||
|
decoded_value = FieldEncryption.decrypt_field(encoded_value)
|
||||||
|
|
||||||
|
# If decoding failed, raise error immediately
|
||||||
|
if decoded_value is None:
|
||||||
|
raise error_class(error_message)
|
||||||
|
|
||||||
|
# Update payload dict in-place with decoded value
|
||||||
|
# Since payload is a mutable dict and get_json() returns the cached reference,
|
||||||
|
# modifying it will affect all subsequent accesses including console_ns.payload
|
||||||
|
payload[field_name] = decoded_value
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_password_field(view: Callable[P, R]):
|
||||||
|
"""
|
||||||
|
Decorator to decrypt password field in request payload.
|
||||||
|
|
||||||
|
Automatically decrypts the 'password' field if encryption is enabled.
|
||||||
|
If decryption fails, raises AuthenticationFailedError.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@decrypt_password_field
|
||||||
|
def post(self):
|
||||||
|
args = LoginPayload.model_validate(console_ns.payload)
|
||||||
|
# args.password is now decrypted
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(view)
|
||||||
|
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||||
|
_decrypt_field(FIELD_NAME_PASSWORD, AuthenticationFailedError, ERROR_MSG_INVALID_ENCRYPTED_DATA)
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_code_field(view: Callable[P, R]):
|
||||||
|
"""
|
||||||
|
Decorator to decrypt verification code field in request payload.
|
||||||
|
|
||||||
|
Automatically decrypts the 'code' field if encryption is enabled.
|
||||||
|
If decryption fails, raises EmailCodeError.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@decrypt_code_field
|
||||||
|
def post(self):
|
||||||
|
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
|
||||||
|
# args.code is now decrypted
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(view)
|
||||||
|
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||||
|
_decrypt_field(FIELD_NAME_CODE, EmailCodeError, ERROR_MSG_INVALID_ENCRYPTED_CODE)
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|||||||
66
api/libs/encryption.py
Normal file
66
api/libs/encryption.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Field Encoding/Decoding Utilities
|
||||||
|
|
||||||
|
Provides Base64 decoding for sensitive fields (password, verification code)
|
||||||
|
received from the frontend.
|
||||||
|
|
||||||
|
Note: This uses Base64 encoding for obfuscation, not cryptographic encryption.
|
||||||
|
Real security relies on HTTPS for transport layer encryption.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FieldEncryption:
|
||||||
|
"""Handle decoding of sensitive fields during transmission"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decrypt_field(cls, encoded_text: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Decode Base64 encoded field from frontend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encoded_text: Base64 encoded text from frontend
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded plaintext, or None if decoding fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Decode base64
|
||||||
|
decoded_bytes = base64.b64decode(encoded_text)
|
||||||
|
decoded_text = decoded_bytes.decode("utf-8")
|
||||||
|
logger.debug("Field decoding successful")
|
||||||
|
return decoded_text
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Decoding failed - return None to trigger error in caller
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decrypt_password(cls, encrypted_password: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Decrypt password field
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted_password: Encrypted password from frontend
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted password or None if decryption fails
|
||||||
|
"""
|
||||||
|
return cls.decrypt_field(encrypted_password)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decrypt_verification_code(cls, encrypted_code: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Decrypt verification code field
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted_code: Encrypted code from frontend
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted code or None if decryption fails
|
||||||
|
"""
|
||||||
|
return cls.decrypt_field(encrypted_code)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Test authentication security to prevent user enumeration."""
|
"""Test authentication security to prevent user enumeration."""
|
||||||
|
|
||||||
|
import base64
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -11,6 +12,11 @@ from controllers.console.auth.error import AuthenticationFailedError
|
|||||||
from controllers.console.auth.login import LoginApi
|
from controllers.console.auth.login import LoginApi
|
||||||
|
|
||||||
|
|
||||||
|
def encode_password(password: str) -> str:
|
||||||
|
"""Helper to encode password as Base64 for testing."""
|
||||||
|
return base64.b64encode(password.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
|
||||||
class TestAuthenticationSecurity:
|
class TestAuthenticationSecurity:
|
||||||
"""Test authentication endpoints for security against user enumeration."""
|
"""Test authentication endpoints for security against user enumeration."""
|
||||||
|
|
||||||
@@ -42,7 +48,9 @@ class TestAuthenticationSecurity:
|
|||||||
|
|
||||||
# Act
|
# Act
|
||||||
with self.app.test_request_context(
|
with self.app.test_request_context(
|
||||||
"/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"}
|
"/login",
|
||||||
|
method="POST",
|
||||||
|
json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")},
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
|
|
||||||
@@ -72,7 +80,9 @@ class TestAuthenticationSecurity:
|
|||||||
|
|
||||||
# Act
|
# Act
|
||||||
with self.app.test_request_context(
|
with self.app.test_request_context(
|
||||||
"/login", method="POST", json={"email": "existing@example.com", "password": "WrongPass123!"}
|
"/login",
|
||||||
|
method="POST",
|
||||||
|
json={"email": "existing@example.com", "password": encode_password("WrongPass123!")},
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
|
|
||||||
@@ -104,7 +114,9 @@ class TestAuthenticationSecurity:
|
|||||||
|
|
||||||
# Act
|
# Act
|
||||||
with self.app.test_request_context(
|
with self.app.test_request_context(
|
||||||
"/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"}
|
"/login",
|
||||||
|
method="POST",
|
||||||
|
json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")},
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ This module tests the email code login mechanism including:
|
|||||||
- Workspace creation for new users
|
- Workspace creation for new users
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -25,6 +26,11 @@ from controllers.console.error import (
|
|||||||
from services.errors.account import AccountRegisterError
|
from services.errors.account import AccountRegisterError
|
||||||
|
|
||||||
|
|
||||||
|
def encode_code(code: str) -> str:
|
||||||
|
"""Helper to encode verification code as Base64 for testing."""
|
||||||
|
return base64.b64encode(code.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
|
||||||
class TestEmailCodeLoginSendEmailApi:
|
class TestEmailCodeLoginSendEmailApi:
|
||||||
"""Test cases for sending email verification codes."""
|
"""Test cases for sending email verification codes."""
|
||||||
|
|
||||||
@@ -290,7 +296,7 @@ class TestEmailCodeLoginApi:
|
|||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/email-code-login/validity",
|
"/email-code-login/validity",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "test@example.com", "code": "123456", "token": "valid_token"},
|
json={"email": "test@example.com", "code": encode_code("123456"), "token": "valid_token"},
|
||||||
):
|
):
|
||||||
api = EmailCodeLoginApi()
|
api = EmailCodeLoginApi()
|
||||||
response = api.post()
|
response = api.post()
|
||||||
@@ -339,7 +345,12 @@ class TestEmailCodeLoginApi:
|
|||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/email-code-login/validity",
|
"/email-code-login/validity",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "newuser@example.com", "code": "123456", "token": "valid_token", "language": "en-US"},
|
json={
|
||||||
|
"email": "newuser@example.com",
|
||||||
|
"code": encode_code("123456"),
|
||||||
|
"token": "valid_token",
|
||||||
|
"language": "en-US",
|
||||||
|
},
|
||||||
):
|
):
|
||||||
api = EmailCodeLoginApi()
|
api = EmailCodeLoginApi()
|
||||||
response = api.post()
|
response = api.post()
|
||||||
@@ -365,7 +376,7 @@ class TestEmailCodeLoginApi:
|
|||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/email-code-login/validity",
|
"/email-code-login/validity",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "test@example.com", "code": "123456", "token": "invalid_token"},
|
json={"email": "test@example.com", "code": encode_code("123456"), "token": "invalid_token"},
|
||||||
):
|
):
|
||||||
api = EmailCodeLoginApi()
|
api = EmailCodeLoginApi()
|
||||||
with pytest.raises(InvalidTokenError):
|
with pytest.raises(InvalidTokenError):
|
||||||
@@ -388,7 +399,7 @@ class TestEmailCodeLoginApi:
|
|||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/email-code-login/validity",
|
"/email-code-login/validity",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "different@example.com", "code": "123456", "token": "token"},
|
json={"email": "different@example.com", "code": encode_code("123456"), "token": "token"},
|
||||||
):
|
):
|
||||||
api = EmailCodeLoginApi()
|
api = EmailCodeLoginApi()
|
||||||
with pytest.raises(InvalidEmailError):
|
with pytest.raises(InvalidEmailError):
|
||||||
@@ -411,7 +422,7 @@ class TestEmailCodeLoginApi:
|
|||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/email-code-login/validity",
|
"/email-code-login/validity",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "test@example.com", "code": "wrong_code", "token": "token"},
|
json={"email": "test@example.com", "code": encode_code("wrong_code"), "token": "token"},
|
||||||
):
|
):
|
||||||
api = EmailCodeLoginApi()
|
api = EmailCodeLoginApi()
|
||||||
with pytest.raises(EmailCodeError):
|
with pytest.raises(EmailCodeError):
|
||||||
@@ -497,7 +508,7 @@ class TestEmailCodeLoginApi:
|
|||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/email-code-login/validity",
|
"/email-code-login/validity",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "test@example.com", "code": "123456", "token": "token"},
|
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
|
||||||
):
|
):
|
||||||
api = EmailCodeLoginApi()
|
api = EmailCodeLoginApi()
|
||||||
with pytest.raises(WorkspacesLimitExceeded):
|
with pytest.raises(WorkspacesLimitExceeded):
|
||||||
@@ -539,7 +550,7 @@ class TestEmailCodeLoginApi:
|
|||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/email-code-login/validity",
|
"/email-code-login/validity",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "test@example.com", "code": "123456", "token": "token"},
|
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
|
||||||
):
|
):
|
||||||
api = EmailCodeLoginApi()
|
api = EmailCodeLoginApi()
|
||||||
with pytest.raises(NotAllowedCreateWorkspace):
|
with pytest.raises(NotAllowedCreateWorkspace):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ This module tests the core authentication endpoints including:
|
|||||||
- Account status validation
|
- Account status validation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -28,6 +29,11 @@ from controllers.console.error import (
|
|||||||
from services.errors.account import AccountLoginError, AccountPasswordError
|
from services.errors.account import AccountLoginError, AccountPasswordError
|
||||||
|
|
||||||
|
|
||||||
|
def encode_password(password: str) -> str:
|
||||||
|
"""Helper to encode password as Base64 for testing."""
|
||||||
|
return base64.b64encode(password.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
|
||||||
class TestLoginApi:
|
class TestLoginApi:
|
||||||
"""Test cases for the LoginApi endpoint."""
|
"""Test cases for the LoginApi endpoint."""
|
||||||
|
|
||||||
@@ -106,7 +112,9 @@ class TestLoginApi:
|
|||||||
|
|
||||||
# Act
|
# Act
|
||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"}
|
"/login",
|
||||||
|
method="POST",
|
||||||
|
json={"email": "test@example.com", "password": encode_password("ValidPass123!")},
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
response = login_api.post()
|
response = login_api.post()
|
||||||
@@ -158,7 +166,11 @@ class TestLoginApi:
|
|||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/login",
|
"/login",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "test@example.com", "password": "ValidPass123!", "invite_token": "valid_token"},
|
json={
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": encode_password("ValidPass123!"),
|
||||||
|
"invite_token": "valid_token",
|
||||||
|
},
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
response = login_api.post()
|
response = login_api.post()
|
||||||
@@ -186,7 +198,7 @@ class TestLoginApi:
|
|||||||
|
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/login", method="POST", json={"email": "test@example.com", "password": "password"}
|
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")}
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
with pytest.raises(EmailPasswordLoginLimitError):
|
with pytest.raises(EmailPasswordLoginLimitError):
|
||||||
@@ -209,7 +221,7 @@ class TestLoginApi:
|
|||||||
|
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/login", method="POST", json={"email": "frozen@example.com", "password": "password"}
|
"/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")}
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
with pytest.raises(AccountInFreezeError):
|
with pytest.raises(AccountInFreezeError):
|
||||||
@@ -246,7 +258,7 @@ class TestLoginApi:
|
|||||||
|
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/login", method="POST", json={"email": "test@example.com", "password": "WrongPass123!"}
|
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("WrongPass123!")}
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
with pytest.raises(AuthenticationFailedError):
|
with pytest.raises(AuthenticationFailedError):
|
||||||
@@ -277,7 +289,7 @@ class TestLoginApi:
|
|||||||
|
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/login", method="POST", json={"email": "banned@example.com", "password": "ValidPass123!"}
|
"/login", method="POST", json={"email": "banned@example.com", "password": encode_password("ValidPass123!")}
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
with pytest.raises(AccountBannedError):
|
with pytest.raises(AccountBannedError):
|
||||||
@@ -322,7 +334,7 @@ class TestLoginApi:
|
|||||||
|
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"}
|
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("ValidPass123!")}
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
with pytest.raises(WorkspacesLimitExceeded):
|
with pytest.raises(WorkspacesLimitExceeded):
|
||||||
@@ -349,7 +361,11 @@ class TestLoginApi:
|
|||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/login",
|
"/login",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "different@example.com", "password": "ValidPass123!", "invite_token": "token"},
|
json={
|
||||||
|
"email": "different@example.com",
|
||||||
|
"password": encode_password("ValidPass123!"),
|
||||||
|
"invite_token": "token",
|
||||||
|
},
|
||||||
):
|
):
|
||||||
login_api = LoginApi()
|
login_api = LoginApi()
|
||||||
with pytest.raises(InvalidEmailError):
|
with pytest.raises(InvalidEmailError):
|
||||||
|
|||||||
150
api/tests/unit_tests/libs/test_encryption.py
Normal file
150
api/tests/unit_tests/libs/test_encryption.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for field encoding/decoding utilities.
|
||||||
|
|
||||||
|
These tests verify Base64 encoding/decoding functionality and
|
||||||
|
proper error handling and fallback behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from libs.encryption import FieldEncryption
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecodeField:
|
||||||
|
"""Test cases for field decoding functionality."""
|
||||||
|
|
||||||
|
def test_decode_valid_base64(self):
|
||||||
|
"""Test decoding a valid Base64 encoded string."""
|
||||||
|
plaintext = "password123"
|
||||||
|
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
result = FieldEncryption.decrypt_field(encoded)
|
||||||
|
assert result == plaintext
|
||||||
|
|
||||||
|
def test_decode_non_base64_returns_none(self):
|
||||||
|
"""Test that non-base64 input returns None."""
|
||||||
|
non_base64 = "plain-password-!@#"
|
||||||
|
result = FieldEncryption.decrypt_field(non_base64)
|
||||||
|
# Should return None (decoding failed)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_decode_unicode_text(self):
|
||||||
|
"""Test decoding Base64 encoded Unicode text."""
|
||||||
|
plaintext = "密码Test123"
|
||||||
|
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
result = FieldEncryption.decrypt_field(encoded)
|
||||||
|
assert result == plaintext
|
||||||
|
|
||||||
|
def test_decode_empty_string(self):
|
||||||
|
"""Test decoding an empty string returns empty string."""
|
||||||
|
result = FieldEncryption.decrypt_field("")
|
||||||
|
# Empty string base64 decodes to empty string
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_decode_special_characters(self):
|
||||||
|
"""Test decoding with special characters."""
|
||||||
|
plaintext = "P@ssw0rd!#$%^&*()"
|
||||||
|
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
result = FieldEncryption.decrypt_field(encoded)
|
||||||
|
assert result == plaintext
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecodePassword:
|
||||||
|
"""Test cases for password decoding."""
|
||||||
|
|
||||||
|
def test_decode_password_base64(self):
|
||||||
|
"""Test decoding a Base64 encoded password."""
|
||||||
|
password = "SecureP@ssw0rd!"
|
||||||
|
encoded = base64.b64encode(password.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
result = FieldEncryption.decrypt_password(encoded)
|
||||||
|
assert result == password
|
||||||
|
|
||||||
|
def test_decode_password_invalid_returns_none(self):
|
||||||
|
"""Test that invalid base64 passwords return None."""
|
||||||
|
invalid = "PlainPassword!@#"
|
||||||
|
result = FieldEncryption.decrypt_password(invalid)
|
||||||
|
# Should return None (decoding failed)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecodeVerificationCode:
|
||||||
|
"""Test cases for verification code decoding."""
|
||||||
|
|
||||||
|
def test_decode_code_base64(self):
|
||||||
|
"""Test decoding a Base64 encoded verification code."""
|
||||||
|
code = "789012"
|
||||||
|
encoded = base64.b64encode(code.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
result = FieldEncryption.decrypt_verification_code(encoded)
|
||||||
|
assert result == code
|
||||||
|
|
||||||
|
def test_decode_code_invalid_returns_none(self):
|
||||||
|
"""Test that invalid base64 codes return None."""
|
||||||
|
invalid = "123456" # Plain 6-digit code, not base64
|
||||||
|
result = FieldEncryption.decrypt_verification_code(invalid)
|
||||||
|
# Should return None (decoding failed)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoundTripEncodingDecoding:
|
||||||
|
"""
|
||||||
|
Integration tests for complete encoding-decoding cycle.
|
||||||
|
These tests simulate the full frontend-to-backend flow using Base64.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_roundtrip_password(self):
|
||||||
|
"""Test encoding and decoding a password."""
|
||||||
|
original_password = "SecureP@ssw0rd!"
|
||||||
|
|
||||||
|
# Simulate frontend encoding (Base64)
|
||||||
|
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
# Backend decoding
|
||||||
|
decoded = FieldEncryption.decrypt_password(encoded)
|
||||||
|
|
||||||
|
assert decoded == original_password
|
||||||
|
|
||||||
|
def test_roundtrip_verification_code(self):
|
||||||
|
"""Test encoding and decoding a verification code."""
|
||||||
|
original_code = "123456"
|
||||||
|
|
||||||
|
# Simulate frontend encoding
|
||||||
|
encoded = base64.b64encode(original_code.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
# Backend decoding
|
||||||
|
decoded = FieldEncryption.decrypt_verification_code(encoded)
|
||||||
|
|
||||||
|
assert decoded == original_code
|
||||||
|
|
||||||
|
def test_roundtrip_unicode_password(self):
|
||||||
|
"""Test encoding and decoding password with Unicode characters."""
|
||||||
|
original_password = "密码Test123!@#"
|
||||||
|
|
||||||
|
# Frontend encoding
|
||||||
|
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
|
||||||
|
|
||||||
|
# Backend decoding
|
||||||
|
decoded = FieldEncryption.decrypt_password(encoded)
|
||||||
|
|
||||||
|
assert decoded == original_password
|
||||||
|
|
||||||
|
def test_roundtrip_long_password(self):
|
||||||
|
"""Test encoding and decoding a long password."""
|
||||||
|
original_password = "ThisIsAVeryLongPasswordWithLotsOfCharacters123!@#$%^&*()"
|
||||||
|
|
||||||
|
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
|
||||||
|
decoded = FieldEncryption.decrypt_password(encoded)
|
||||||
|
|
||||||
|
assert decoded == original_password
|
||||||
|
|
||||||
|
def test_roundtrip_with_whitespace(self):
|
||||||
|
"""Test encoding and decoding with whitespace."""
|
||||||
|
original_password = "pass word with spaces"
|
||||||
|
|
||||||
|
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
|
||||||
|
decoded = FieldEncryption.decrypt_field(encoded)
|
||||||
|
|
||||||
|
assert decoded == original_password
|
||||||
@@ -12,6 +12,7 @@ import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
|
|||||||
import I18NContext from '@/context/i18n'
|
import I18NContext from '@/context/i18n'
|
||||||
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
|
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
|
||||||
import { trackEvent } from '@/app/components/base/amplitude'
|
import { trackEvent } from '@/app/components/base/amplitude'
|
||||||
|
import { encryptVerificationCode } from '@/utils/encryption'
|
||||||
|
|
||||||
export default function CheckCode() {
|
export default function CheckCode() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
@@ -43,7 +44,7 @@ export default function CheckCode() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const ret = await emailLoginWithCode({ email, code, token, language })
|
const ret = await emailLoginWithCode({ email, code: encryptVerificationCode(code), token, language })
|
||||||
if (ret.result === 'success') {
|
if (ret.result === 'success') {
|
||||||
// Track login success event
|
// Track login success event
|
||||||
trackEvent('user_login_success', {
|
trackEvent('user_login_success', {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { noop } from 'lodash-es'
|
|||||||
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
|
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
|
||||||
import type { ResponseError } from '@/service/fetch'
|
import type { ResponseError } from '@/service/fetch'
|
||||||
import { trackEvent } from '@/app/components/base/amplitude'
|
import { trackEvent } from '@/app/components/base/amplitude'
|
||||||
|
import { encryptPassword } from '@/utils/encryption'
|
||||||
|
|
||||||
type MailAndPasswordAuthProps = {
|
type MailAndPasswordAuthProps = {
|
||||||
isInvite: boolean
|
isInvite: boolean
|
||||||
@@ -53,7 +54,7 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const loginData: Record<string, any> = {
|
const loginData: Record<string, any> = {
|
||||||
email,
|
email,
|
||||||
password,
|
password: encryptPassword(password),
|
||||||
language: locale,
|
language: locale,
|
||||||
remember_me: true,
|
remember_me: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ export NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=${LOOP_NODE_MAX_COUNT}
|
|||||||
export NEXT_PUBLIC_MAX_PARALLEL_LIMIT=${MAX_PARALLEL_LIMIT}
|
export NEXT_PUBLIC_MAX_PARALLEL_LIMIT=${MAX_PARALLEL_LIMIT}
|
||||||
export NEXT_PUBLIC_MAX_ITERATIONS_NUM=${MAX_ITERATIONS_NUM}
|
export NEXT_PUBLIC_MAX_ITERATIONS_NUM=${MAX_ITERATIONS_NUM}
|
||||||
export NEXT_PUBLIC_MAX_TREE_DEPTH=${MAX_TREE_DEPTH}
|
export NEXT_PUBLIC_MAX_TREE_DEPTH=${MAX_TREE_DEPTH}
|
||||||
|
|
||||||
pm2 start /app/web/server.js --name dify-web --cwd /app/web -i ${PM2_INSTANCES} --no-daemon
|
pm2 start /app/web/server.js --name dify-web --cwd /app/web -i ${PM2_INSTANCES} --no-daemon
|
||||||
|
|||||||
46
web/utils/encryption.ts
Normal file
46
web/utils/encryption.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Field Encoding Utilities
|
||||||
|
* Provides Base64 encoding for sensitive fields (password, verification code)
|
||||||
|
* during transmission from frontend to backend.
|
||||||
|
*
|
||||||
|
* Note: This uses Base64 encoding for obfuscation, not cryptographic encryption.
|
||||||
|
* Real security relies on HTTPS for transport layer encryption.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode sensitive field using Base64
|
||||||
|
* @param plaintext - The plain text to encode
|
||||||
|
* @returns Base64 encoded text
|
||||||
|
*/
|
||||||
|
export function encryptField(plaintext: string): string {
|
||||||
|
try {
|
||||||
|
// Base64 encode the plaintext
|
||||||
|
// btoa works with ASCII, so we need to handle UTF-8 properly
|
||||||
|
const utf8Bytes = new TextEncoder().encode(plaintext)
|
||||||
|
const base64 = btoa(String.fromCharCode(...utf8Bytes))
|
||||||
|
return base64
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Field encoding failed:', error)
|
||||||
|
// If encoding fails, throw error to prevent sending plaintext
|
||||||
|
throw new Error('Encoding failed. Please check your input.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt password field for login
|
||||||
|
* @param password - Plain password
|
||||||
|
* @returns Encrypted password or original if encryption disabled
|
||||||
|
*/
|
||||||
|
export function encryptPassword(password: string): string {
|
||||||
|
return encryptField(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt verification code for email code login
|
||||||
|
* @param code - Plain verification code
|
||||||
|
* @returns Encrypted code or original if encryption disabled
|
||||||
|
*/
|
||||||
|
export function encryptVerificationCode(code: string): string {
|
||||||
|
return encryptField(code)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user