Browse Source

Feat/new login (#8120)

Co-authored-by: douxc <douxc512@gmail.com>
Co-authored-by: Garfield Dai <dai.hai@foxmail.com>
Joe 5 months ago
parent
commit
4fd2743efa

+ 1 - 1
api/.env.example

@@ -326,4 +326,4 @@ POSITION_TOOL_EXCLUDES=
 
 POSITION_PROVIDER_PINS=
 POSITION_PROVIDER_INCLUDES=
-POSITION_PROVIDER_EXCLUDES=
+POSITION_PROVIDER_EXCLUDES=

+ 45 - 3
api/configs/feature/__init__.py

@@ -1,6 +1,15 @@
-from typing import Annotated, Optional
-
-from pydantic import AliasChoices, Field, HttpUrl, NegativeInt, NonNegativeInt, PositiveInt, computed_field
+from typing import Annotated, Literal, Optional
+
+from pydantic import (
+    AliasChoices,
+    Field,
+    HttpUrl,
+    NegativeInt,
+    NonNegativeInt,
+    PositiveFloat,
+    PositiveInt,
+    computed_field,
+)
 from pydantic_settings import BaseSettings
 
 from configs.feature.hosted_service import HostedServiceConfig
@@ -473,6 +482,11 @@ class MailConfig(BaseSettings):
         default=False,
     )
 
+    EMAIL_SEND_IP_LIMIT_PER_MINUTE: PositiveInt = Field(
+        description="Maximum number of emails allowed to be sent from the same IP address in a minute",
+        default=50,
+    )
+
 
 class RagEtlConfig(BaseSettings):
     """
@@ -614,6 +628,33 @@ class PositionConfig(BaseSettings):
         return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
 
 
+class LoginConfig(BaseSettings):
+    ENABLE_EMAIL_CODE_LOGIN: bool = Field(
+        description="whether to enable email code login",
+        default=False,
+    )
+    ENABLE_EMAIL_PASSWORD_LOGIN: bool = Field(
+        description="whether to enable email password login",
+        default=True,
+    )
+    ENABLE_SOCIAL_OAUTH_LOGIN: bool = Field(
+        description="whether to enable github/google oauth login",
+        default=False,
+    )
+    EMAIL_CODE_LOGIN_TOKEN_EXPIRY_HOURS: PositiveFloat = Field(
+        description="expiry time in hours for email code login token",
+        default=1 / 12,
+    )
+    ALLOW_REGISTER: bool = Field(
+        description="whether to enable register",
+        default=False,
+    )
+    ALLOW_CREATE_WORKSPACE: bool = Field(
+        description="whether to enable create workspace",
+        default=False,
+    )
+
+
 class FeatureConfig(
     # place the configs in alphabet order
     AppExecutionConfig,
@@ -639,6 +680,7 @@ class FeatureConfig(
     UpdateConfig,
     WorkflowConfig,
     WorkspaceConfig,
+    LoginConfig,
     # hosted services config
     HostedServiceConfig,
     CeleryBeatConfig,

+ 19 - 19
api/controllers/console/auth/activate.py

@@ -1,17 +1,15 @@
-import base64
 import datetime
-import secrets
 
+from flask import request
 from flask_restful import Resource, reqparse
 
 from constants.languages import supported_language
 from controllers.console import api
 from controllers.console.error import AlreadyActivateError
 from extensions.ext_database import db
-from libs.helper import StrLen, email, timezone
-from libs.password import hash_password, valid_password
-from models.account import AccountStatus
-from services.account_service import RegisterService
+from libs.helper import StrLen, email, extract_remote_ip, timezone
+from models.account import AccountStatus, Tenant
+from services.account_service import AccountService, RegisterService
 
 
 class ActivateCheckApi(Resource):
@@ -27,8 +25,18 @@ class ActivateCheckApi(Resource):
         token = args["token"]
 
         invitation = RegisterService.get_invitation_if_token_valid(workspaceId, reg_email, token)
-
-        return {"is_valid": invitation is not None, "workspace_name": invitation["tenant"].name if invitation else None}
+        if invitation:
+            data = invitation.get("data", {})
+            tenant: Tenant = invitation.get("tenant", None)
+            workspace_name = tenant.name if tenant else None
+            workspace_id = tenant.id if tenant else None
+            invitee_email = data.get("email") if data else None
+            return {
+                "is_valid": invitation is not None,
+                "data": {"workspace_name": workspace_name, "workspace_id": workspace_id, "email": invitee_email},
+            }
+        else:
+            return {"is_valid": False}
 
 
 class ActivateApi(Resource):
@@ -38,7 +46,6 @@ class ActivateApi(Resource):
         parser.add_argument("email", type=email, required=False, nullable=True, location="json")
         parser.add_argument("token", type=str, required=True, nullable=False, location="json")
         parser.add_argument("name", type=StrLen(30), required=True, nullable=False, location="json")
-        parser.add_argument("password", type=valid_password, required=True, nullable=False, location="json")
         parser.add_argument(
             "interface_language", type=supported_language, required=True, nullable=False, location="json"
         )
@@ -54,15 +61,6 @@ class ActivateApi(Resource):
         account = invitation["account"]
         account.name = args["name"]
 
-        # generate password salt
-        salt = secrets.token_bytes(16)
-        base64_salt = base64.b64encode(salt).decode()
-
-        # encrypt password with salt
-        password_hashed = hash_password(args["password"], salt)
-        base64_password_hashed = base64.b64encode(password_hashed).decode()
-        account.password = base64_password_hashed
-        account.password_salt = base64_salt
         account.interface_language = args["interface_language"]
         account.timezone = args["timezone"]
         account.interface_theme = "light"
@@ -70,7 +68,9 @@ class ActivateApi(Resource):
         account.initialized_at = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
         db.session.commit()
 
-        return {"result": "success"}
+        token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
+
+        return {"result": "success", "data": token_pair.model_dump()}
 
 
 api.add_resource(ActivateCheckApi, "/activate/check")

+ 25 - 1
api/controllers/console/auth/error.py

@@ -27,5 +27,29 @@ class InvalidTokenError(BaseHTTPException):
 
 class PasswordResetRateLimitExceededError(BaseHTTPException):
     error_code = "password_reset_rate_limit_exceeded"
-    description = "Password reset rate limit exceeded. Try again later."
+    description = "Too many password reset emails have been sent. Please try again in 1 minutes."
+    code = 429
+
+
+class EmailCodeError(BaseHTTPException):
+    error_code = "email_code_error"
+    description = "Email code is invalid or expired."
+    code = 400
+
+
+class EmailOrPasswordMismatchError(BaseHTTPException):
+    error_code = "email_or_password_mismatch"
+    description = "The email or password is mismatched."
+    code = 400
+
+
+class EmailPasswordLoginLimitError(BaseHTTPException):
+    error_code = "email_code_login_limit"
+    description = "Too many incorrect password attempts. Please try again later."
+    code = 429
+
+
+class EmailCodeLoginRateLimitExceededError(BaseHTTPException):
+    error_code = "email_code_login_rate_limit_exceeded"
+    description = "Too many login emails have been sent. Please try again in 5 minutes."
     code = 429

+ 63 - 29
api/controllers/console/auth/forgot_password.py

@@ -1,65 +1,82 @@
 import base64
-import logging
 import secrets
 
+from flask import request
 from flask_restful import Resource, reqparse
 
+from constants.languages import languages
 from controllers.console import api
 from controllers.console.auth.error import (
+    EmailCodeError,
     InvalidEmailError,
     InvalidTokenError,
     PasswordMismatchError,
-    PasswordResetRateLimitExceededError,
 )
+from controllers.console.error import EmailSendIpLimitError, NotAllowedRegister
 from controllers.console.setup import setup_required
+from events.tenant_event import tenant_was_created
 from extensions.ext_database import db
-from libs.helper import email as email_validate
+from libs.helper import email, extract_remote_ip
 from libs.password import hash_password, valid_password
 from models.account import Account
-from services.account_service import AccountService
-from services.errors.account import RateLimitExceededError
+from services.account_service import AccountService, TenantService
+from services.errors.workspace import WorkSpaceNotAllowedCreateError
+from services.feature_service import FeatureService
 
 
 class ForgotPasswordSendEmailApi(Resource):
     @setup_required
     def post(self):
         parser = reqparse.RequestParser()
-        parser.add_argument("email", type=str, required=True, location="json")
+        parser.add_argument("email", type=email, required=True, location="json")
+        parser.add_argument("language", type=str, required=False, location="json")
         args = parser.parse_args()
 
-        email = args["email"]
-
-        if not email_validate(email):
-            raise InvalidEmailError()
+        ip_address = extract_remote_ip(request)
+        if AccountService.is_email_send_ip_limit(ip_address):
+            raise EmailSendIpLimitError()
 
-        account = Account.query.filter_by(email=email).first()
-
-        if account:
-            try:
-                AccountService.send_reset_password_email(account=account)
-            except RateLimitExceededError:
-                logging.warning(f"Rate limit exceeded for email: {account.email}")
-                raise PasswordResetRateLimitExceededError()
+        if args["language"] is not None and args["language"] == "zh-Hans":
+            language = "zh-Hans"
+        else:
+            language = "en-US"
+
+        account = Account.query.filter_by(email=args["email"]).first()
+        token = None
+        if account is None:
+            if FeatureService.get_system_features().is_allow_register:
+                token = AccountService.send_reset_password_email(email=args["email"], language=language)
+                return {"result": "fail", "data": token, "code": "account_not_found"}
+            else:
+                raise NotAllowedRegister()
         else:
-            # Return success to avoid revealing email registration status
-            logging.warning(f"Attempt to reset password for unregistered email: {email}")
+            token = AccountService.send_reset_password_email(account=account, email=args["email"], language=language)
 
-        return {"result": "success"}
+        return {"result": "success", "data": token}
 
 
 class ForgotPasswordCheckApi(Resource):
     @setup_required
     def post(self):
         parser = reqparse.RequestParser()
+        parser.add_argument("email", type=str, required=True, location="json")
+        parser.add_argument("code", type=str, required=True, location="json")
         parser.add_argument("token", type=str, required=True, nullable=False, location="json")
         args = parser.parse_args()
-        token = args["token"]
 
-        reset_data = AccountService.get_reset_password_data(token)
+        user_email = args["email"]
 
-        if reset_data is None:
-            return {"is_valid": False, "email": None}
-        return {"is_valid": True, "email": reset_data.get("email")}
+        token_data = AccountService.get_reset_password_data(args["token"])
+        if token_data is None:
+            raise InvalidTokenError()
+
+        if user_email != token_data.get("email"):
+            raise InvalidEmailError()
+
+        if args["code"] != token_data.get("code"):
+            raise EmailCodeError()
+
+        return {"is_valid": True, "email": token_data.get("email")}
 
 
 class ForgotPasswordResetApi(Resource):
@@ -92,9 +109,26 @@ class ForgotPasswordResetApi(Resource):
         base64_password_hashed = base64.b64encode(password_hashed).decode()
 
         account = Account.query.filter_by(email=reset_data.get("email")).first()
-        account.password = base64_password_hashed
-        account.password_salt = base64_salt
-        db.session.commit()
+        if account:
+            account.password = base64_password_hashed
+            account.password_salt = base64_salt
+            db.session.commit()
+            tenant = TenantService.get_join_tenants(account)
+            if not tenant and not FeatureService.get_system_features().is_allow_create_workspace:
+                tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
+                TenantService.create_tenant_member(tenant, account, role="owner")
+                account.current_tenant = tenant
+                tenant_was_created.send(tenant)
+        else:
+            try:
+                account = AccountService.create_account_and_tenant(
+                    email=reset_data.get("email"),
+                    name=reset_data.get("email"),
+                    password=password_confirm,
+                    interface_language=languages[0],
+                )
+            except WorkSpaceNotAllowedCreateError:
+                pass
 
         return {"result": "success"}
 

+ 158 - 55
api/controllers/console/auth/login.py

@@ -1,16 +1,34 @@
 from typing import cast
 
 import flask_login
-from flask import request
+from flask import redirect, request
 from flask_restful import Resource, reqparse
 
 import services
+from configs import dify_config
+from constants.languages import languages
 from controllers.console import api
+from controllers.console.auth.error import (
+    EmailCodeError,
+    EmailOrPasswordMismatchError,
+    EmailPasswordLoginLimitError,
+    InvalidEmailError,
+    InvalidTokenError,
+)
+from controllers.console.error import (
+    AccountBannedError,
+    EmailSendIpLimitError,
+    NotAllowedCreateWorkspace,
+    NotAllowedRegister,
+)
 from controllers.console.setup import setup_required
+from events.tenant_event import tenant_was_created
 from libs.helper import email, extract_remote_ip
 from libs.password import valid_password
 from models.account import Account
-from services.account_service import AccountService, TenantService
+from services.account_service import AccountService, RegisterService, TenantService
+from services.errors.workspace import WorkSpaceNotAllowedCreateError
+from services.feature_service import FeatureService
 
 
 class LoginApi(Resource):
@@ -23,15 +41,43 @@ class LoginApi(Resource):
         parser.add_argument("email", type=email, required=True, location="json")
         parser.add_argument("password", type=valid_password, required=True, location="json")
         parser.add_argument("remember_me", type=bool, required=False, default=False, location="json")
+        parser.add_argument("invite_token", type=str, required=False, default=None, location="json")
+        parser.add_argument("language", type=str, required=False, default="en-US", location="json")
         args = parser.parse_args()
 
-        # todo: Verify the recaptcha
+        is_login_error_rate_limit = AccountService.is_login_error_rate_limit(args["email"])
+        if is_login_error_rate_limit:
+            raise EmailPasswordLoginLimitError()
 
-        try:
-            account = AccountService.authenticate(args["email"], args["password"])
-        except services.errors.account.AccountLoginError as e:
-            return {"code": "unauthorized", "message": str(e)}, 401
+        invitation = args["invite_token"]
+        if invitation:
+            invitation = RegisterService.get_invitation_if_token_valid(None, args["email"], invitation)
+
+        if args["language"] is not None and args["language"] == "zh-Hans":
+            language = "zh-Hans"
+        else:
+            language = "en-US"
 
+        try:
+            if invitation:
+                data = invitation.get("data", {})
+                invitee_email = data.get("email") if data else None
+                if invitee_email != args["email"]:
+                    raise InvalidEmailError()
+                account = AccountService.authenticate(args["email"], args["password"], args["invite_token"])
+            else:
+                account = AccountService.authenticate(args["email"], args["password"])
+        except services.errors.account.AccountLoginError:
+            raise AccountBannedError()
+        except services.errors.account.AccountPasswordError:
+            AccountService.add_login_error_rate_limit(args["email"])
+            raise EmailOrPasswordMismatchError()
+        except services.errors.account.AccountNotFoundError:
+            if FeatureService.get_system_features().is_allow_register:
+                token = AccountService.send_reset_password_email(email=args["email"], language=language)
+                return {"result": "fail", "data": token, "code": "account_not_found"}
+            else:
+                raise NotAllowedRegister()
         # SELF_HOSTED only have one workspace
         tenants = TenantService.get_join_tenants(account)
         if len(tenants) == 0:
@@ -41,7 +87,7 @@ class LoginApi(Resource):
             }
 
         token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
-
+        AccountService.reset_login_error_rate_limit(args["email"])
         return {"result": "success", "data": token_pair.model_dump()}
 
 
@@ -49,60 +95,114 @@ class LogoutApi(Resource):
     @setup_required
     def get(self):
         account = cast(Account, flask_login.current_user)
+        if isinstance(account, flask_login.AnonymousUserMixin):
+            return {"result": "success"}
         AccountService.logout(account=account)
         flask_login.logout_user()
         return {"result": "success"}
 
 
-class ResetPasswordApi(Resource):
+class ResetPasswordSendEmailApi(Resource):
     @setup_required
-    def get(self):
-        # parser = reqparse.RequestParser()
-        # parser.add_argument('email', type=email, required=True, location='json')
-        # args = parser.parse_args()
-
-        # import mailchimp_transactional as MailchimpTransactional
-        # from mailchimp_transactional.api_client import ApiClientError
-
-        # account = {'email': args['email']}
-        # account = AccountService.get_by_email(args['email'])
-        # if account is None:
-        #     raise ValueError('Email not found')
-        # new_password = AccountService.generate_password()
-        # AccountService.update_password(account, new_password)
-
-        # todo: Send email
-        # MAILCHIMP_API_KEY = dify_config.MAILCHIMP_TRANSACTIONAL_API_KEY
-        # mailchimp = MailchimpTransactional(MAILCHIMP_API_KEY)
-
-        # message = {
-        #     'from_email': 'noreply@example.com',
-        #     'to': [{'email': account['email']}],
-        #     'subject': 'Reset your Dify password',
-        #     'html': """
-        #         <p>Dear User,</p>
-        #         <p>The Dify team has generated a new password for you, details as follows:</p>
-        #         <p><strong>{new_password}</strong></p>
-        #         <p>Please change your password to log in as soon as possible.</p>
-        #         <p>Regards,</p>
-        #         <p>The Dify Team</p>
-        #     """
-        # }
-
-        # response = mailchimp.messages.send({
-        #     'message': message,
-        #     # required for transactional email
-        #     ' settings': {
-        #         'sandbox_mode': dify_config.MAILCHIMP_SANDBOX_MODE,
-        #     },
-        # })
-
-        # Check if MSG was sent
-        # if response.status_code != 200:
-        #     # handle error
-        #     pass
+    def post(self):
+        parser = reqparse.RequestParser()
+        parser.add_argument("email", type=email, required=True, location="json")
+        parser.add_argument("language", type=str, required=False, location="json")
+        args = parser.parse_args()
 
-        return {"result": "success"}
+        if args["language"] is not None and args["language"] == "zh-Hans":
+            language = "zh-Hans"
+        else:
+            language = "en-US"
+
+        account = AccountService.get_user_through_email(args["email"])
+        if account is None:
+            if FeatureService.get_system_features().is_allow_register:
+                token = AccountService.send_reset_password_email(email=args["email"], language=language)
+            else:
+                raise NotAllowedRegister()
+        else:
+            token = AccountService.send_reset_password_email(account=account, language=language)
+
+        return {"result": "success", "data": token}
+
+
+class EmailCodeLoginSendEmailApi(Resource):
+    @setup_required
+    def post(self):
+        parser = reqparse.RequestParser()
+        parser.add_argument("email", type=email, required=True, location="json")
+        parser.add_argument("language", type=str, required=False, location="json")
+        args = parser.parse_args()
+
+        ip_address = extract_remote_ip(request)
+        if AccountService.is_email_send_ip_limit(ip_address):
+            raise EmailSendIpLimitError()
+
+        if args["language"] is not None and args["language"] == "zh-Hans":
+            language = "zh-Hans"
+        else:
+            language = "en-US"
+
+        account = AccountService.get_user_through_email(args["email"])
+        if account is None:
+            if FeatureService.get_system_features().is_allow_register:
+                token = AccountService.send_email_code_login_email(email=args["email"], language=language)
+            else:
+                raise NotAllowedRegister()
+        else:
+            token = AccountService.send_email_code_login_email(account=account, language=language)
+
+        return {"result": "success", "data": token}
+
+
+class EmailCodeLoginApi(Resource):
+    @setup_required
+    def post(self):
+        parser = reqparse.RequestParser()
+        parser.add_argument("email", type=str, required=True, location="json")
+        parser.add_argument("code", type=str, required=True, location="json")
+        parser.add_argument("token", type=str, required=True, location="json")
+        args = parser.parse_args()
+
+        user_email = args["email"]
+
+        token_data = AccountService.get_email_code_login_data(args["token"])
+        if token_data is None:
+            raise InvalidTokenError()
+
+        if token_data["email"] != args["email"]:
+            raise InvalidEmailError()
+
+        if token_data["code"] != args["code"]:
+            raise EmailCodeError()
+
+        AccountService.revoke_email_code_login_token(args["token"])
+        account = AccountService.get_user_through_email(user_email)
+        if account:
+            tenant = TenantService.get_join_tenants(account)
+            if not tenant:
+                if not FeatureService.get_system_features().is_allow_create_workspace:
+                    raise NotAllowedCreateWorkspace()
+                else:
+                    tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
+                    TenantService.create_tenant_member(tenant, account, role="owner")
+                    account.current_tenant = tenant
+                    tenant_was_created.send(tenant)
+
+        if account is None:
+            try:
+                account = AccountService.create_account_and_tenant(
+                    email=user_email, name=user_email, interface_language=languages[0]
+                )
+            except WorkSpaceNotAllowedCreateError:
+                return redirect(
+                    f"{dify_config.CONSOLE_WEB_URL}/signin"
+                    "?message=Workspace not found, please contact system admin to invite you to join in a workspace."
+                )
+        token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
+        AccountService.reset_login_error_rate_limit(args["email"])
+        return {"result": "success", "data": token_pair.model_dump()}
 
 
 class RefreshTokenApi(Resource):
@@ -120,4 +220,7 @@ class RefreshTokenApi(Resource):
 
 api.add_resource(LoginApi, "/login")
 api.add_resource(LogoutApi, "/logout")
+api.add_resource(EmailCodeLoginSendEmailApi, "/email-code-login")
+api.add_resource(EmailCodeLoginApi, "/email-code-login/validity")
+api.add_resource(ResetPasswordSendEmailApi, "/reset-password")
 api.add_resource(RefreshTokenApi, "/refresh-token")

+ 55 - 4
api/controllers/console/auth/oauth.py

@@ -5,14 +5,19 @@ from typing import Optional
 import requests
 from flask import current_app, redirect, request
 from flask_restful import Resource
+from werkzeug.exceptions import Unauthorized
 
 from configs import dify_config
 from constants.languages import languages
+from events.tenant_event import tenant_was_created
 from extensions.ext_database import db
 from libs.helper import extract_remote_ip
 from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo
 from models.account import Account, AccountStatus
 from services.account_service import AccountService, RegisterService, TenantService
+from services.errors.account import AccountNotFoundError
+from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
+from services.feature_service import FeatureService
 
 from .. import api
 
@@ -42,6 +47,7 @@ def get_oauth_providers():
 
 class OAuthLogin(Resource):
     def get(self, provider: str):
+        invite_token = request.args.get("invite_token") or None
         OAUTH_PROVIDERS = get_oauth_providers()
         with current_app.app_context():
             oauth_provider = OAUTH_PROVIDERS.get(provider)
@@ -49,7 +55,7 @@ class OAuthLogin(Resource):
         if not oauth_provider:
             return {"error": "Invalid provider"}, 400
 
-        auth_url = oauth_provider.get_authorization_url()
+        auth_url = oauth_provider.get_authorization_url(invite_token=invite_token)
         return redirect(auth_url)
 
 
@@ -62,6 +68,11 @@ class OAuthCallback(Resource):
             return {"error": "Invalid provider"}, 400
 
         code = request.args.get("code")
+        state = request.args.get("state")
+        invite_token = None
+        if state:
+            invite_token = state
+
         try:
             token = oauth_provider.get_access_token(code)
             user_info = oauth_provider.get_user_info(token)
@@ -69,7 +80,27 @@ class OAuthCallback(Resource):
             logging.exception(f"An error occurred during the OAuth process with {provider}: {e.response.text}")
             return {"error": "OAuth process failed"}, 400
 
-        account = _generate_account(provider, user_info)
+        if invite_token and RegisterService.is_valid_invite_token(invite_token):
+            invitation = RegisterService._get_invitation_by_token(token=invite_token)
+            if invitation:
+                invitation_email = invitation.get("email", None)
+                if invitation_email != user_info.email:
+                    return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Invalid invitation token.")
+
+            return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}")
+
+        try:
+            account = _generate_account(provider, user_info)
+        except AccountNotFoundError:
+            return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.")
+        except WorkSpaceNotFoundError:
+            return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Workspace not found.")
+        except WorkSpaceNotAllowedCreateError:
+            return redirect(
+                f"{dify_config.CONSOLE_WEB_URL}/signin"
+                "?message=Workspace not found, please contact system admin to invite you to join in a workspace."
+            )
+
         # Check account status
         if account.status in {AccountStatus.BANNED.value, AccountStatus.CLOSED.value}:
             return {"error": "Account is banned or closed."}, 403
@@ -79,7 +110,15 @@ class OAuthCallback(Resource):
             account.initialized_at = datetime.now(timezone.utc).replace(tzinfo=None)
             db.session.commit()
 
-        TenantService.create_owner_tenant_if_not_exist(account)
+        try:
+            TenantService.create_owner_tenant_if_not_exist(account)
+        except Unauthorized:
+            return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Workspace not found.")
+        except WorkSpaceNotAllowedCreateError:
+            return redirect(
+                f"{dify_config.CONSOLE_WEB_URL}/signin"
+                "?message=Workspace not found, please contact system admin to invite you to join in a workspace."
+            )
 
         token_pair = AccountService.login(
             account=account,
@@ -104,8 +143,20 @@ def _generate_account(provider: str, user_info: OAuthUserInfo):
     # Get account by openid or email.
     account = _get_account_by_openid_or_email(provider, user_info)
 
+    if account:
+        tenant = TenantService.get_join_tenants(account)
+        if not tenant:
+            if not FeatureService.get_system_features().is_allow_create_workspace:
+                raise WorkSpaceNotAllowedCreateError()
+            else:
+                tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
+                TenantService.create_tenant_member(tenant, account, role="owner")
+                account.current_tenant = tenant
+                tenant_was_created.send(tenant)
+
     if not account:
-        # Create account
+        if not FeatureService.get_system_features().is_allow_register:
+            raise AccountNotFoundError()
         account_name = user_info.name or "Dify"
         account = RegisterService.register(
             email=user_info.email, name=account_name, password=None, open_id=user_info.id, provider=provider

+ 24 - 0
api/controllers/console/error.py

@@ -38,3 +38,27 @@ class AlreadyActivateError(BaseHTTPException):
     error_code = "already_activate"
     description = "Auth Token is invalid or account already activated, please check again."
     code = 403
+
+
+class NotAllowedCreateWorkspace(BaseHTTPException):
+    error_code = "unauthorized"
+    description = "Workspace not found, please contact system admin to invite you to join in a workspace."
+    code = 400
+
+
+class AccountBannedError(BaseHTTPException):
+    error_code = "account_banned"
+    description = "Account is banned."
+    code = 400
+
+
+class NotAllowedRegister(BaseHTTPException):
+    error_code = "unauthorized"
+    description = "Account not found."
+    code = 400
+
+
+class EmailSendIpLimitError(BaseHTTPException):
+    error_code = "email_send_ip_limit"
+    description = "Too many emails have been sent from this IP address recently. Please try again later."
+    code = 429

+ 30 - 11
api/libs/helper.py

@@ -189,23 +189,39 @@ def compact_generate_response(response: Union[dict, RateLimitGenerator]) -> Resp
 
 class TokenManager:
     @classmethod
-    def generate_token(cls, account: Account, token_type: str, additional_data: Optional[dict] = None) -> str:
-        old_token = cls._get_current_token_for_account(account.id, token_type)
-        if old_token:
-            if isinstance(old_token, bytes):
-                old_token = old_token.decode("utf-8")
-            cls.revoke_token(old_token, token_type)
+    def generate_token(
+        cls,
+        token_type: str,
+        account: Optional[Account] = None,
+        email: Optional[str] = None,
+        additional_data: Optional[dict] = None,
+    ) -> str:
+        if account is None and email is None:
+            raise ValueError("Account or email must be provided")
+
+        account_id = account.id if account else None
+        account_email = account.email if account else email
+
+        if account_id:
+            old_token = cls._get_current_token_for_account(account_id, token_type)
+            if old_token:
+                if isinstance(old_token, bytes):
+                    old_token = old_token.decode("utf-8")
+                cls.revoke_token(old_token, token_type)
 
         token = str(uuid.uuid4())
-        token_data = {"account_id": account.id, "email": account.email, "token_type": token_type}
+        token_data = {"account_id": account_id, "email": account_email, "token_type": token_type}
         if additional_data:
             token_data.update(additional_data)
 
         expiry_hours = current_app.config[f"{token_type.upper()}_TOKEN_EXPIRY_HOURS"]
         token_key = cls._get_token_key(token, token_type)
-        redis_client.setex(token_key, expiry_hours * 60 * 60, json.dumps(token_data))
+        expiry_time = int(expiry_hours * 60 * 60)
+        redis_client.setex(token_key, expiry_time, json.dumps(token_data))
+
+        if account_id:
+            cls._set_current_token_for_account(account.id, token, token_type, expiry_hours)
 
-        cls._set_current_token_for_account(account.id, token, token_type, expiry_hours)
         return token
 
     @classmethod
@@ -234,9 +250,12 @@ class TokenManager:
         return current_token
 
     @classmethod
-    def _set_current_token_for_account(cls, account_id: str, token: str, token_type: str, expiry_hours: int):
+    def _set_current_token_for_account(
+        cls, account_id: str, token: str, token_type: str, expiry_hours: Union[int, float]
+    ):
         key = cls._get_account_token_key(account_id, token_type)
-        redis_client.setex(key, expiry_hours * 60 * 60, token)
+        expiry_time = int(expiry_hours * 60 * 60)
+        redis_client.setex(key, expiry_time, token)
 
     @classmethod
     def _get_account_token_key(cls, account_id: str, token_type: str) -> str:

+ 7 - 2
api/libs/oauth.py

@@ -1,5 +1,6 @@
 import urllib.parse
 from dataclasses import dataclass
+from typing import Optional
 
 import requests
 
@@ -40,12 +41,14 @@ class GitHubOAuth(OAuth):
     _USER_INFO_URL = "https://api.github.com/user"
     _EMAIL_INFO_URL = "https://api.github.com/user/emails"
 
-    def get_authorization_url(self):
+    def get_authorization_url(self, invite_token: Optional[str] = None):
         params = {
             "client_id": self.client_id,
             "redirect_uri": self.redirect_uri,
             "scope": "user:email",  # Request only basic user information
         }
+        if invite_token:
+            params["state"] = invite_token
         return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
 
     def get_access_token(self, code: str):
@@ -90,13 +93,15 @@ class GoogleOAuth(OAuth):
     _TOKEN_URL = "https://oauth2.googleapis.com/token"
     _USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
 
-    def get_authorization_url(self):
+    def get_authorization_url(self, invite_token: Optional[str] = None):
         params = {
             "client_id": self.client_id,
             "response_type": "code",
             "redirect_uri": self.redirect_uri,
             "scope": "openid email",
         }
+        if invite_token:
+            params["state"] = invite_token
         return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
 
     def get_access_token(self, code: str):

+ 1 - 1
api/libs/password.py

@@ -13,7 +13,7 @@ def valid_password(password):
     if re.match(pattern, password) is not None:
         return password
 
-    raise ValueError("Not a valid password.")
+    raise ValueError("Password must contain letters and numbers, and the length must be greater than 8.")
 
 
 def hash_password(password_str, salt_byte):

+ 228 - 27
api/services/account_service.py

@@ -1,6 +1,7 @@
 import base64
 import json
 import logging
+import random
 import secrets
 import uuid
 from datetime import datetime, timedelta, timezone
@@ -34,7 +35,9 @@ from models.model import DifySetup
 from services.errors.account import (
     AccountAlreadyInTenantError,
     AccountLoginError,
+    AccountNotFoundError,
     AccountNotLinkTenantError,
+    AccountPasswordError,
     AccountRegisterError,
     CannotOperateSelfError,
     CurrentPasswordIncorrectError,
@@ -42,10 +45,12 @@ from services.errors.account import (
     LinkAccountIntegrateError,
     MemberNotInTenantError,
     NoPermissionError,
-    RateLimitExceededError,
     RoleAlreadyAssignedError,
     TenantNotFoundError,
 )
+from services.errors.workspace import WorkSpaceNotAllowedCreateError
+from services.feature_service import FeatureService
+from tasks.mail_email_code_login import send_email_code_login_mail_task
 from tasks.mail_invite_member_task import send_invite_member_mail_task
 from tasks.mail_reset_password_task import send_reset_password_mail_task
 
@@ -61,7 +66,11 @@ REFRESH_TOKEN_EXPIRY = timedelta(days=30)
 
 
 class AccountService:
-    reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=5, time_window=60 * 60)
+    reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
+    email_code_login_rate_limiter = RateLimiter(
+        prefix="email_code_login_rate_limit", max_attempts=1, time_window=60 * 1
+    )
+    LOGIN_MAX_ERROR_LIMITS = 5
 
     @staticmethod
     def _get_refresh_token_key(refresh_token: str) -> str:
@@ -127,23 +136,34 @@ class AccountService:
         return token
 
     @staticmethod
-    def authenticate(email: str, password: str) -> Account:
+    def authenticate(email: str, password: str, invite_token: Optional[str] = None) -> Account:
         """authenticate account with email and password"""
 
         account = Account.query.filter_by(email=email).first()
         if not account:
-            raise AccountLoginError("Invalid email or password.")
+            raise AccountNotFoundError()
 
         if account.status in {AccountStatus.BANNED.value, AccountStatus.CLOSED.value}:
             raise AccountLoginError("Account is banned or closed.")
 
+        if password and invite_token and account.password is None:
+            # if invite_token is valid, set password and password_salt
+            salt = secrets.token_bytes(16)
+            base64_salt = base64.b64encode(salt).decode()
+            password_hashed = hash_password(password, salt)
+            base64_password_hashed = base64.b64encode(password_hashed).decode()
+            account.password = base64_password_hashed
+            account.password_salt = base64_salt
+
+        if account.password is None or not compare_password(password, account.password, account.password_salt):
+            raise AccountPasswordError("Invalid email or password.")
+
         if account.status == AccountStatus.PENDING.value:
             account.status = AccountStatus.ACTIVE.value
             account.initialized_at = datetime.now(timezone.utc).replace(tzinfo=None)
-            db.session.commit()
 
-        if account.password is None or not compare_password(password, account.password, account.password_salt):
-            raise AccountLoginError("Invalid email or password.")
+        db.session.commit()
+
         return account
 
     @staticmethod
@@ -169,9 +189,18 @@ class AccountService:
 
     @staticmethod
     def create_account(
-        email: str, name: str, interface_language: str, password: Optional[str] = None, interface_theme: str = "light"
+        email: str,
+        name: str,
+        interface_language: str,
+        password: Optional[str] = None,
+        interface_theme: str = "light",
+        is_setup: Optional[bool] = False,
     ) -> Account:
         """create account"""
+        if not FeatureService.get_system_features().is_allow_register and not is_setup:
+            from controllers.console.error import NotAllowedRegister
+
+            raise NotAllowedRegister()
         account = Account()
         account.email = email
         account.name = name
@@ -198,6 +227,19 @@ class AccountService:
         db.session.commit()
         return account
 
+    @staticmethod
+    def create_account_and_tenant(
+        email: str, name: str, interface_language: str, password: Optional[str] = None
+    ) -> Account:
+        """create account"""
+        account = AccountService.create_account(
+            email=email, name=name, interface_language=interface_language, password=password
+        )
+
+        TenantService.create_owner_tenant_if_not_exist(account=account)
+
+        return account
+
     @staticmethod
     def link_account_integrate(provider: str, open_id: str, account: Account) -> None:
         """Link account integrate"""
@@ -256,6 +298,10 @@ class AccountService:
         if ip_address:
             AccountService.update_login_info(account=account, ip_address=ip_address)
 
+        if account.status == AccountStatus.PENDING.value:
+            account.status = AccountStatus.ACTIVE.value
+            db.session.commit()
+
         access_token = AccountService.get_account_jwt_token(account=account)
         refresh_token = _generate_refresh_token()
 
@@ -294,13 +340,29 @@ class AccountService:
         return AccountService.load_user(account_id)
 
     @classmethod
-    def send_reset_password_email(cls, account):
-        if cls.reset_password_rate_limiter.is_rate_limited(account.email):
-            raise RateLimitExceededError(f"Rate limit exceeded for email: {account.email}. Please try again later.")
+    def send_reset_password_email(
+        cls,
+        account: Optional[Account] = None,
+        email: Optional[str] = None,
+        language: Optional[str] = "en-US",
+    ):
+        account_email = account.email if account else email
 
-        token = TokenManager.generate_token(account, "reset_password")
-        send_reset_password_mail_task.delay(language=account.interface_language, to=account.email, token=token)
-        cls.reset_password_rate_limiter.increment_rate_limit(account.email)
+        if cls.reset_password_rate_limiter.is_rate_limited(account_email):
+            from controllers.console.auth.error import PasswordResetRateLimitExceededError
+
+            raise PasswordResetRateLimitExceededError()
+
+        code = "".join([str(random.randint(0, 9)) for _ in range(6)])
+        token = TokenManager.generate_token(
+            account=account, email=email, token_type="reset_password", additional_data={"code": code}
+        )
+        send_reset_password_mail_task.delay(
+            language=language,
+            to=account_email,
+            code=code,
+        )
+        cls.reset_password_rate_limiter.increment_rate_limit(account_email)
         return token
 
     @classmethod
@@ -311,11 +373,125 @@ class AccountService:
     def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]:
         return TokenManager.get_token_data(token, "reset_password")
 
+    @classmethod
+    def send_email_code_login_email(
+        cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US"
+    ):
+        if cls.email_code_login_rate_limiter.is_rate_limited(email):
+            from controllers.console.auth.error import EmailCodeLoginRateLimitExceededError
+
+            raise EmailCodeLoginRateLimitExceededError()
+
+        code = "".join([str(random.randint(0, 9)) for _ in range(6)])
+        token = TokenManager.generate_token(
+            account=account, 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,
+        )
+        cls.email_code_login_rate_limiter.increment_rate_limit(email)
+        return token
+
+    @classmethod
+    def get_email_code_login_data(cls, token: str) -> Optional[dict[str, Any]]:
+        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 get_user_through_email(cls, email: str):
+        account = db.session.query(Account).filter(Account.email == email).first()
+        if not account:
+            return None
+
+        if account.status in {AccountStatus.BANNED.value, AccountStatus.CLOSED.value}:
+            raise Unauthorized("Account is banned or closed.")
+
+        return account
+
+    @staticmethod
+    def add_login_error_rate_limit(email: str) -> None:
+        key = f"login_error_rate_limit:{email}"
+        count = redis_client.get(key)
+        if count is None:
+            count = 0
+        count = int(count) + 1
+        redis_client.setex(key, 60 * 60 * 24, count)
+
+    @staticmethod
+    def is_login_error_rate_limit(email: str) -> bool:
+        key = f"login_error_rate_limit:{email}"
+        count = redis_client.get(key)
+        if count is None:
+            return False
+
+        count = int(count)
+        if count > AccountService.LOGIN_MAX_ERROR_LIMITS:
+            return True
+        return False
+
+    @staticmethod
+    def reset_login_error_rate_limit(email: str):
+        key = f"login_error_rate_limit:{email}"
+        redis_client.delete(key)
+
+    @staticmethod
+    def is_email_send_ip_limit(ip_address: str):
+        minute_key = f"email_send_ip_limit_minute:{ip_address}"
+        freeze_key = f"email_send_ip_limit_freeze:{ip_address}"
+        hour_limit_key = f"email_send_ip_limit_hour:{ip_address}"
+
+        # check ip is frozen
+        if redis_client.get(freeze_key):
+            return True
+
+        # check current minute count
+        current_minute_count = redis_client.get(minute_key)
+        if current_minute_count is None:
+            current_minute_count = 0
+        current_minute_count = int(current_minute_count)
+
+        # check current hour count
+        if current_minute_count > dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE:
+            hour_limit_count = redis_client.get(hour_limit_key)
+            if hour_limit_count is None:
+                hour_limit_count = 0
+            hour_limit_count = int(hour_limit_count)
+
+            if hour_limit_count >= 1:
+                redis_client.setex(freeze_key, 60 * 60, 1)
+                return True
+            else:
+                redis_client.setex(hour_limit_key, 60 * 10, hour_limit_count + 1)  # first time limit 10 minutes
+
+            # add hour limit count
+            redis_client.incr(hour_limit_key)
+            redis_client.expire(hour_limit_key, 60 * 60)
+
+            return True
+
+        redis_client.setex(minute_key, 60, current_minute_count + 1)
+        redis_client.expire(minute_key, 60)
+
+        return False
+
+
+def _get_login_cache_key(*, account_id: str, token: str):
+    return f"account_login:{account_id}:{token}"
+
 
 class TenantService:
     @staticmethod
-    def create_tenant(name: str) -> Tenant:
+    def create_tenant(name: str, is_setup: Optional[bool] = False) -> Tenant:
         """Create tenant"""
+        if not FeatureService.get_system_features().is_allow_create_workspace and not is_setup:
+            from controllers.console.error import NotAllowedCreateWorkspace
+
+            raise NotAllowedCreateWorkspace()
         tenant = Tenant(name=name)
 
         db.session.add(tenant)
@@ -326,8 +502,12 @@ class TenantService:
         return tenant
 
     @staticmethod
-    def create_owner_tenant_if_not_exist(account: Account, name: Optional[str] = None):
+    def create_owner_tenant_if_not_exist(
+        account: Account, name: Optional[str] = None, is_setup: Optional[bool] = False
+    ):
         """Create owner tenant if not exist"""
+        if not FeatureService.get_system_features().is_allow_create_workspace and not is_setup:
+            raise WorkSpaceNotAllowedCreateError()
         available_ta = (
             TenantAccountJoin.query.filter_by(account_id=account.id).order_by(TenantAccountJoin.id.asc()).first()
         )
@@ -336,9 +516,9 @@ class TenantService:
             return
 
         if name:
-            tenant = TenantService.create_tenant(name)
+            tenant = TenantService.create_tenant(name=name, is_setup=is_setup)
         else:
-            tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
+            tenant = TenantService.create_tenant(name=f"{account.name}'s Workspace", is_setup=is_setup)
         TenantService.create_tenant_member(tenant, account, role="owner")
         account.current_tenant = tenant
         db.session.commit()
@@ -352,8 +532,13 @@ class TenantService:
                 logging.error(f"Tenant {tenant.id} has already an owner.")
                 raise Exception("Tenant already has an owner.")
 
-        ta = TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, role=role)
-        db.session.add(ta)
+        ta = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=account.id).first()
+        if ta:
+            ta.role = role
+        else:
+            ta = TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, role=role)
+            db.session.add(ta)
+
         db.session.commit()
         return ta
 
@@ -570,12 +755,13 @@ class RegisterService:
                 name=name,
                 interface_language=languages[0],
                 password=password,
+                is_setup=True,
             )
 
             account.last_login_ip = ip_address
             account.initialized_at = datetime.now(timezone.utc).replace(tzinfo=None)
 
-            TenantService.create_owner_tenant_if_not_exist(account)
+            TenantService.create_owner_tenant_if_not_exist(account=account, is_setup=True)
 
             dify_setup = DifySetup(version=dify_config.CURRENT_VERSION)
             db.session.add(dify_setup)
@@ -600,27 +786,33 @@ class RegisterService:
         provider: Optional[str] = None,
         language: Optional[str] = None,
         status: Optional[AccountStatus] = None,
+        is_setup: Optional[bool] = False,
     ) -> Account:
         db.session.begin_nested()
         """Register account"""
         try:
             account = AccountService.create_account(
-                email=email, name=name, interface_language=language or languages[0], password=password
+                email=email,
+                name=name,
+                interface_language=language or languages[0],
+                password=password,
+                is_setup=is_setup,
             )
             account.status = AccountStatus.ACTIVE.value if not status else status.value
             account.initialized_at = datetime.now(timezone.utc).replace(tzinfo=None)
 
             if open_id is not None or provider is not None:
                 AccountService.link_account_integrate(provider, open_id, account)
-            if dify_config.EDITION != "SELF_HOSTED":
-                tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
 
+            if FeatureService.get_system_features().is_allow_create_workspace:
+                tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
                 TenantService.create_tenant_member(tenant, account, role="owner")
                 account.current_tenant = tenant
-
                 tenant_was_created.send(tenant)
 
             db.session.commit()
+        except WorkSpaceNotAllowedCreateError:
+            db.session.rollback()
         except Exception as e:
             db.session.rollback()
             logging.error(f"Register failed: {e}")
@@ -639,7 +831,9 @@ class RegisterService:
             TenantService.check_member_permission(tenant, inviter, None, "add")
             name = email.split("@")[0]
 
-            account = cls.register(email=email, name=name, language=language, status=AccountStatus.PENDING)
+            account = cls.register(
+                email=email, name=name, language=language, status=AccountStatus.PENDING, is_setup=True
+            )
             # Create new tenant member for invited tenant
             TenantService.create_tenant_member(tenant, account, role)
             TenantService.switch_tenant(account, tenant.id)
@@ -679,6 +873,11 @@ class RegisterService:
         redis_client.setex(cls._get_invitation_token_key(token), expiry_hours * 60 * 60, json.dumps(invitation_data))
         return token
 
+    @classmethod
+    def is_valid_invite_token(cls, token: str) -> bool:
+        data = redis_client.get(cls._get_invitation_token_key(token))
+        return data is not None
+
     @classmethod
     def revoke_token(cls, workspace_id: str, email: str, token: str):
         if workspace_id and email:
@@ -727,7 +926,9 @@ class RegisterService:
         }
 
     @classmethod
-    def _get_invitation_by_token(cls, token: str, workspace_id: str, email: str) -> Optional[dict[str, str]]:
+    def _get_invitation_by_token(
+        cls, token: str, workspace_id: Optional[str] = None, email: Optional[str] = None
+    ) -> Optional[dict[str, str]]:
         if workspace_id is not None and email is not None:
             email_hash = sha256(email.encode()).hexdigest()
             cache_key = f"member_invite_token:{workspace_id}, {email_hash}:{token}"

+ 4 - 0
api/services/errors/account.py

@@ -13,6 +13,10 @@ class AccountLoginError(BaseServiceError):
     pass
 
 
+class AccountPasswordError(BaseServiceError):
+    pass
+
+
 class AccountNotLinkTenantError(BaseServiceError):
     pass
 

+ 9 - 0
api/services/errors/workspace.py

@@ -0,0 +1,9 @@
+from services.errors.base import BaseServiceError
+
+
+class WorkSpaceNotAllowedCreateError(BaseServiceError):
+    pass
+
+
+class WorkSpaceNotFoundError(BaseServiceError):
+    pass

+ 32 - 4
api/services/feature_service.py

@@ -42,6 +42,11 @@ class SystemFeatureModel(BaseModel):
     sso_enforced_for_web: bool = False
     sso_enforced_for_web_protocol: str = ""
     enable_web_sso_switch_component: bool = False
+    enable_email_code_login: bool = False
+    enable_email_password_login: bool = True
+    enable_social_oauth_login: bool = False
+    is_allow_register: bool = False
+    is_allow_create_workspace: bool = False
 
 
 class FeatureService:
@@ -60,12 +65,23 @@ class FeatureService:
     def get_system_features(cls) -> SystemFeatureModel:
         system_features = SystemFeatureModel()
 
+        cls._fulfill_system_params_from_env(system_features)
+
         if dify_config.ENTERPRISE_ENABLED:
             system_features.enable_web_sso_switch_component = True
+
             cls._fulfill_params_from_enterprise(system_features)
 
         return system_features
 
+    @classmethod
+    def _fulfill_system_params_from_env(cls, system_features: SystemFeatureModel):
+        system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
+        system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
+        system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN
+        system_features.is_allow_register = dify_config.ALLOW_REGISTER
+        system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
+
     @classmethod
     def _fulfill_params_from_env(cls, features: FeatureModel):
         features.can_replace_logo = dify_config.CAN_REPLACE_LOGO
@@ -113,7 +129,19 @@ class FeatureService:
     def _fulfill_params_from_enterprise(cls, features):
         enterprise_info = EnterpriseService.get_info()
 
-        features.sso_enforced_for_signin = enterprise_info["sso_enforced_for_signin"]
-        features.sso_enforced_for_signin_protocol = enterprise_info["sso_enforced_for_signin_protocol"]
-        features.sso_enforced_for_web = enterprise_info["sso_enforced_for_web"]
-        features.sso_enforced_for_web_protocol = enterprise_info["sso_enforced_for_web_protocol"]
+        if "sso_enforced_for_signin" in enterprise_info:
+            features.sso_enforced_for_signin = enterprise_info["sso_enforced_for_signin"]
+        if "sso_enforced_for_signin_protocol" in enterprise_info:
+            features.sso_enforced_for_signin_protocol = enterprise_info["sso_enforced_for_signin_protocol"]
+        if "sso_enforced_for_web" in enterprise_info:
+            features.sso_enforced_for_web = enterprise_info["sso_enforced_for_web"]
+        if "sso_enforced_for_web_protocol" in enterprise_info:
+            features.sso_enforced_for_web_protocol = enterprise_info["sso_enforced_for_web_protocol"]
+        if "enable_email_code_login" in enterprise_info:
+            features.enable_email_code_login = enterprise_info["enable_email_code_login"]
+        if "enable_email_password_login" in enterprise_info:
+            features.enable_email_password_login = enterprise_info["enable_email_password_login"]
+        if "is_allow_register" in enterprise_info:
+            features.is_allow_register = enterprise_info["is_allow_register"]
+        if "is_allow_create_workspace" in enterprise_info:
+            features.is_allow_create_workspace = enterprise_info["is_allow_create_workspace"]

+ 41 - 0
api/tasks/mail_email_code_login.py

@@ -0,0 +1,41 @@
+import logging
+import time
+
+import click
+from celery import shared_task
+from flask import render_template
+
+from extensions.ext_mail import mail
+
+
+@shared_task(queue="mail")
+def send_email_code_login_mail_task(language: str, to: str, code: str):
+    """
+    Async Send email code login mail
+    :param language: Language in which the email should be sent (e.g., 'en', 'zh')
+    :param to: Recipient email address
+    :param code: Email code to be included in the email
+    """
+    if not mail.is_inited():
+        return
+
+    logging.info(click.style("Start email code login mail to {}".format(to), fg="green"))
+    start_at = time.perf_counter()
+
+    # send email code login mail using different languages
+    try:
+        if language == "zh-Hans":
+            html_content = render_template("email_code_login_mail_template_zh-CN.html", to=to, code=code)
+            mail.send(to=to, subject="邮箱验证码", html=html_content)
+        else:
+            html_content = render_template("email_code_login_mail_template_en-US.html", to=to, code=code)
+            mail.send(to=to, subject="Email Code", html=html_content)
+
+        end_at = time.perf_counter()
+        logging.info(
+            click.style(
+                "Send email code login mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green"
+            )
+        )
+    except Exception:
+        logging.exception("Send email code login mail to {} failed".format(to))

+ 6 - 8
api/tasks/mail_reset_password_task.py

@@ -5,17 +5,16 @@ import click
 from celery import shared_task
 from flask import render_template
 
-from configs import dify_config
 from extensions.ext_mail import mail
 
 
 @shared_task(queue="mail")
-def send_reset_password_mail_task(language: str, to: str, token: str):
+def send_reset_password_mail_task(language: str, to: str, code: str):
     """
     Async Send reset password mail
     :param language: Language in which the email should be sent (e.g., 'en', 'zh')
     :param to: Recipient email address
-    :param token: Reset password token to be included in the email
+    :param code: Reset password code
     """
     if not mail.is_inited():
         return
@@ -25,13 +24,12 @@ def send_reset_password_mail_task(language: str, to: str, token: str):
 
     # send reset password mail using different languages
     try:
-        url = f"{dify_config.CONSOLE_WEB_URL}/forgot-password?token={token}"
         if language == "zh-Hans":
-            html_content = render_template("reset_password_mail_template_zh-CN.html", to=to, url=url)
-            mail.send(to=to, subject="置您的 Dify 密码", html=html_content)
+            html_content = render_template("reset_password_mail_template_zh-CN.html", to=to, code=code)
+            mail.send(to=to, subject="置您的 Dify 密码", html=html_content)
         else:
-            html_content = render_template("reset_password_mail_template_en-US.html", to=to, url=url)
-            mail.send(to=to, subject="Reset Your Dify Password", html=html_content)
+            html_content = render_template("reset_password_mail_template_en-US.html", to=to, code=code)
+            mail.send(to=to, subject="Set Your Dify Password", html=html_content)
 
         end_at = time.perf_counter()
         logging.info(

+ 74 - 0
api/templates/email_code_login_mail_template_en-US.html

@@ -0,0 +1,74 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <style>
+      body {
+        font-family: 'Arial', sans-serif;
+        line-height: 16pt;
+        color: #101828;
+        background-color: #e9ebf0;
+        margin: 0;
+        padding: 0;
+      }
+      .container {
+        width: 600px;
+        height: 360px;
+        margin: 40px auto;
+        padding: 36px 48px;
+        background-color: #fcfcfd;
+        border-radius: 16px;
+        border: 1px solid #ffffff;
+        box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
+      }
+      .header {
+        margin-bottom: 24px;
+      }
+      .header img {
+        max-width: 100px;
+        height: auto;
+      }
+      .title {
+        font-weight: 600;
+        font-size: 24px;
+        line-height: 28.8px;
+      }
+      .description {
+        font-size: 13px;
+        line-height: 16px;
+        color: #676f83;
+        margin-top: 12px;
+      }
+      .code-content {
+        padding: 16px 32px;
+        text-align: center;
+        border-radius: 16px;
+        background-color: #f2f4f7;
+        margin: 16px auto;
+      }
+      .code {
+        line-height: 36px;
+        font-weight: 700;
+        font-size: 30px;
+      }
+      .tips {
+        line-height: 16px;
+        color: #676f83;
+        font-size: 13px;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="container">
+      <div class="header">
+        <!-- Optional: Add a logo or a header image here -->
+        <img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" />
+      </div>
+      <p class="title">Your login code for Dify</p>
+      <p class="description">Copy and paste this code, this code will only be valid for the next 5 minutes.</p>
+      <div class="code-content">
+        <span class="code">{{code}}</span>
+      </div>
+      <p class="tips">If you didn't request a login, don't worry. You can safely ignore this email.</p>
+    </div>
+  </body>
+</html>

+ 74 - 0
api/templates/email_code_login_mail_template_zh-CN.html

@@ -0,0 +1,74 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <style>
+      body {
+        font-family: 'Arial', sans-serif;
+        line-height: 16pt;
+        color: #101828;
+        background-color: #e9ebf0;
+        margin: 0;
+        padding: 0;
+      }
+      .container {
+        width: 600px;
+        height: 360px;
+        margin: 40px auto;
+        padding: 36px 48px;
+        background-color: #fcfcfd;
+        border-radius: 16px;
+        border: 1px solid #ffffff;
+        box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
+      }
+      .header {
+        margin-bottom: 24px;
+      }
+      .header img {
+        max-width: 100px;
+        height: auto;
+      }
+      .title {
+        font-weight: 600;
+        font-size: 24px;
+        line-height: 28.8px;
+      }
+      .description {
+        font-size: 13px;
+        line-height: 16px;
+        color: #676f83;
+        margin-top: 12px;
+      }
+      .code-content {
+        padding: 16px 32px;
+        text-align: center;
+        border-radius: 16px;
+        background-color: #f2f4f7;
+        margin: 16px auto;
+      }
+      .code {
+        line-height: 36px;
+        font-weight: 700;
+        font-size: 30px;
+      }
+      .tips {
+        line-height: 16px;
+        color: #676f83;
+        font-size: 13px;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="container">
+      <div class="header">
+        <!-- Optional: Add a logo or a header image here -->
+        <img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" />
+      </div>
+      <p class="title">Dify 的登录验证码</p>
+      <p class="description">复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p>
+      <div class="code-content">
+        <span class="code">{{code}}</span>
+      </div>
+      <p class="tips">如果您没有请求登录,请不要担心。您可以安全地忽略此电子邮件。</p>
+    </div>
+  </body>
+</html>

+ 1 - 1
api/templates/invite_member_mail_template_en-US.html

@@ -59,7 +59,7 @@
         <div class="content">
             <p>Dear {{ to }},</p>
             <p>{{ inviter_name }} is pleased to invite you to join our workspace on Dify, a platform specifically designed for LLM application development. On Dify, you can explore, create, and collaborate to build and operate AI applications.</p>
-            <p>You can now log in to Dify using the GitHub or Google account associated with this email.</p>
+            <p>Click the button below to log in to Dify and join the workspace.</p>
             <p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
         </div>
         <div class="footer">

+ 1 - 1
api/templates/invite_member_mail_template_zh-CN.html

@@ -59,7 +59,7 @@
         <div class="content">
             <p>尊敬的 {{ to }},</p>
             <p>{{ inviter_name }} 现邀请您加入我们在 Dify 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 Dify 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
-            <p>您现在可以使用与此邮件相对应的 GitHub 或 Google 账号登录 Dify。</p>
+            <p>点击下方按钮即可登录 Dify 并且加入空间。</p>
             <p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
         </div>
         <div class="footer">

+ 67 - 65
api/templates/reset_password_mail_template_en-US.html

@@ -1,72 +1,74 @@
 <!DOCTYPE html>
 <html>
-<head>
+  <head>
     <style>
-        body {
-            font-family: 'Arial', sans-serif;
-            line-height: 16pt;
-            color: #374151;
-            background-color: #E5E7EB;
-            margin: 0;
-            padding: 0;
-        }
-        .container {
-            width: 100%;
-            max-width: 560px;
-            margin: 40px auto;
-            padding: 20px;
-            background-color: #F3F4F6;
-            border-radius: 8px;
-            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
-        }
-        .header {
-            text-align: center;
-            margin-bottom: 20px;
-        }
-        .header img {
-            max-width: 100px;
-            height: auto;
-        }
-        .button {
-            display: inline-block;
-            padding: 12px 24px;
-            background-color: #2970FF;
-            color: white;
-            text-decoration: none;
-            border-radius: 4px;
-            text-align: center;
-            transition: background-color 0.3s ease;
-        }
-        .button:hover {
-            background-color: #265DD4;
-        }
-        .footer {
-            font-size: 0.9em;
-            color: #777777;
-            margin-top: 30px;
-        }
-        .content {
-            margin-top: 20px;
-        }
+      body {
+        font-family: 'Arial', sans-serif;
+        line-height: 16pt;
+        color: #101828;
+        background-color: #e9ebf0;
+        margin: 0;
+        padding: 0;
+      }
+      .container {
+        width: 600px;
+        height: 360px;
+        margin: 40px auto;
+        padding: 36px 48px;
+        background-color: #fcfcfd;
+        border-radius: 16px;
+        border: 1px solid #ffffff;
+        box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
+      }
+      .header {
+        margin-bottom: 24px;
+      }
+      .header img {
+        max-width: 100px;
+        height: auto;
+      }
+      .title {
+        font-weight: 600;
+        font-size: 24px;
+        line-height: 28.8px;
+      }
+      .description {
+        font-size: 13px;
+        line-height: 16px;
+        color: #676f83;
+        margin-top: 12px;
+      }
+      .code-content {
+        padding: 16px 32px;
+        text-align: center;
+        border-radius: 16px;
+        background-color: #f2f4f7;
+        margin: 16px auto;
+      }
+      .code {
+        line-height: 36px;
+        font-weight: 700;
+        font-size: 30px;
+      }
+      .tips {
+        line-height: 16px;
+        color: #676f83;
+        font-size: 13px;
+      }
     </style>
-</head>
-
-<body>
+  </head>
+  <body>
     <div class="container">
-        <div class="header">
-            <img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo">
-        </div>
-        <div class="content">
-            <p>Dear {{ to }},</p>
-            <p>We have received a request to reset your password. If you initiated this request, please click the button below to reset your password:</p>
-            <p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Reset Password</a></p>
-            <p>If you did not request a password reset, please ignore this email and your account will remain secure.</p>
-        </div>
-        <div class="footer">
-            <p>Best regards,</p>
-            <p>Dify Team</p>
-            <p>Please do not reply directly to this email; it is automatically sent by the system.</p>
-        </div>
+      <div class="header">
+        <!-- Optional: Add a logo or a header image here -->
+        <img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" />
+      </div>
+        <p class="title">Set your Dify password</p>
+      <p class="description">Copy and paste this code, this code will only be valid for the next 5 minutes.</p>
+      <div class="code-content">
+        <span class="code">{{code}}</span>
+      </div>
+        <p class="tips">If you didn't request, don't worry. You can safely ignore this email.</p>
     </div>
-</body>
+  </body>
 </html>

+ 67 - 65
api/templates/reset_password_mail_template_zh-CN.html

@@ -1,72 +1,74 @@
 <!DOCTYPE html>
 <html>
-<head>
+  <head>
     <style>
-        body {
-            font-family: 'Arial', sans-serif;
-            line-height: 16pt;
-            color: #374151;
-            background-color: #E5E7EB;
-            margin: 0;
-            padding: 0;
-        }
-        .container {
-            width: 100%;
-            max-width: 560px;
-            margin: 40px auto;
-            padding: 20px;
-            background-color: #F3F4F6;
-            border-radius: 8px;
-            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
-        }
-        .header {
-            text-align: center;
-            margin-bottom: 20px;
-        }
-        .header img {
-            max-width: 100px;
-            height: auto;
-        }
-        .button {
-            display: inline-block;
-            padding: 12px 24px;
-            background-color: #2970FF;
-            color: white;
-            text-decoration: none;
-            border-radius: 4px;
-            text-align: center;
-            transition: background-color 0.3s ease;
-        }
-        .button:hover {
-            background-color: #265DD4;
-        }
-        .footer {
-            font-size: 0.9em;
-            color: #777777;
-            margin-top: 30px;
-        }
-        .content {
-            margin-top: 20px;
-        }
+      body {
+        font-family: 'Arial', sans-serif;
+        line-height: 16pt;
+        color: #101828;
+        background-color: #e9ebf0;
+        margin: 0;
+        padding: 0;
+      }
+      .container {
+        width: 600px;
+        height: 360px;
+        margin: 40px auto;
+        padding: 36px 48px;
+        background-color: #fcfcfd;
+        border-radius: 16px;
+        border: 1px solid #ffffff;
+        box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
+      }
+      .header {
+        margin-bottom: 24px;
+      }
+      .header img {
+        max-width: 100px;
+        height: auto;
+      }
+      .title {
+        font-weight: 600;
+        font-size: 24px;
+        line-height: 28.8px;
+      }
+      .description {
+        font-size: 13px;
+        line-height: 16px;
+        color: #676f83;
+        margin-top: 12px;
+      }
+      .code-content {
+        padding: 16px 32px;
+        text-align: center;
+        border-radius: 16px;
+        background-color: #f2f4f7;
+        margin: 16px auto;
+      }
+      .code {
+        line-height: 36px;
+        font-weight: 700;
+        font-size: 30px;
+      }
+      .tips {
+        line-height: 16px;
+        color: #676f83;
+        font-size: 13px;
+      }
     </style>
-</head>
-
-<body>
+  </head>
+  <body>
     <div class="container">
-        <div class="header">
-            <img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo">
-        </div>
-        <div class="content">
-            <p>尊敬的 {{ to }},</p>
-            <p>我们收到了您关于重置密码的请求。如果是您本人操作,请点击以下按钮重置您的密码:</p>
-            <p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">重置密码</a></p>
-            <p>如果您没有请求重置密码,请忽略此邮件,您的账户信息将保持安全。</p>
-        </div>
-        <div class="footer">
-            <p>此致,</p>
-            <p>Dify 团队</p>
-            <p>请不要直接回复此电子邮件;由系统自动发送。</p>
-        </div>
+      <div class="header">
+        <!-- Optional: Add a logo or a header image here -->
+        <img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo" />
+      </div>
+        <p class="title">设置您的 Dify 账户密码</p>
+      <p class="description">复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p>
+      <div class="code-content">
+        <span class="code">{{code}}</span>
+      </div>
+        <p class="tips">如果您没有请求,请不要担心。您可以安全地忽略此电子邮件。</p>
     </div>
-</body>
+  </body>
 </html>

+ 2 - 2
docker/.env.example

@@ -606,8 +606,7 @@ INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=1000
 INVITE_EXPIRY_HOURS=72
 
 # Reset password token valid time (hours),
-# Default: 24.
-RESET_PASSWORD_TOKEN_EXPIRY_HOURS=24
+RESET_PASSWORD_TOKEN_EXPIRY_HOURS=1/12
 
 # The sandbox service endpoint.
 CODE_EXECUTION_ENDPOINT=http://sandbox:8194
@@ -837,5 +836,6 @@ POSITION_TOOL_EXCLUDES=
 POSITION_PROVIDER_PINS=
 POSITION_PROVIDER_INCLUDES=
 POSITION_PROVIDER_EXCLUDES=
+
 # CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
 CSP_WHITELIST=