diff --git a/api/controllers/service_api/__init__.py b/api/controllers/service_api/__init__.py index aba9e3ecbb..52f3ec9374 100644 --- a/api/controllers/service_api/__init__.py +++ b/api/controllers/service_api/__init__.py @@ -8,3 +8,4 @@ api = ExternalApi(bp) from . import index from .app import app, audio, completion, conversation, file, message, workflow from .dataset import dataset, document, hit_testing, segment, upload_file +from .auth import activate, forgot_password, login \ No newline at end of file diff --git a/api/controllers/service_api/auth/activate.py b/api/controllers/service_api/auth/activate.py new file mode 100644 index 0000000000..f3f7049e5f --- /dev/null +++ b/api/controllers/service_api/auth/activate.py @@ -0,0 +1,77 @@ +import datetime + +from flask import request +from flask_restful import Resource, reqparse # type: ignore + +from constants.languages import supported_language +from controllers.service_api import api +from controllers.service_api.error import AlreadyActivateError +from extensions.ext_database import db +from libs.helper import StrLen, email, extract_remote_ip, timezone +from models.account import AccountStatus +from services.account_service import AccountService, RegisterService + + +class ActivateCheckApi(Resource): + def get(self): + parser = reqparse.RequestParser() + parser.add_argument("workspace_id", type=str, required=False, nullable=True, location="args") + parser.add_argument("email", type=email, required=False, nullable=True, location="args") + parser.add_argument("token", type=str, required=True, nullable=False, location="args") + args = parser.parse_args() + + workspaceId = args["workspace_id"] + reg_email = args["email"] + token = args["token"] + + invitation = RegisterService.get_invitation_if_token_valid(workspaceId, reg_email, token) + if invitation: + data = invitation.get("data", {}) + 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): + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("workspace_id", type=str, required=False, nullable=True, location="json") + 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( + "interface_language", type=supported_language, required=True, nullable=False, location="json" + ) + parser.add_argument("timezone", type=timezone, required=True, nullable=False, location="json") + args = parser.parse_args() + + invitation = RegisterService.get_invitation_if_token_valid(args["workspace_id"], args["email"], args["token"]) + if invitation is None: + raise AlreadyActivateError() + + RegisterService.revoke_token(args["workspace_id"], args["email"], args["token"]) + + account = invitation["account"] + account.name = args["name"] + + account.interface_language = args["interface_language"] + account.timezone = args["timezone"] + account.interface_theme = "light" + account.status = AccountStatus.ACTIVE.value + account.initialized_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) + db.session.commit() + + 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") +api.add_resource(ActivateApi, "/activate") diff --git a/api/controllers/service_api/auth/error.py b/api/controllers/service_api/auth/error.py new file mode 100644 index 0000000000..8ef10c7bbb --- /dev/null +++ b/api/controllers/service_api/auth/error.py @@ -0,0 +1,61 @@ +from libs.exception import BaseHTTPException + + +class ApiKeyAuthFailedError(BaseHTTPException): + error_code = "auth_failed" + description = "{message}" + code = 500 + + +class InvalidEmailError(BaseHTTPException): + error_code = "invalid_email" + description = "The email address is not valid." + code = 400 + + +class PasswordMismatchError(BaseHTTPException): + error_code = "password_mismatch" + description = "The passwords do not match." + code = 400 + + +class InvalidTokenError(BaseHTTPException): + error_code = "invalid_or_expired_token" + description = "The token is invalid or has expired." + code = 400 + + +class PasswordResetRateLimitExceededError(BaseHTTPException): + error_code = "password_reset_rate_limit_exceeded" + 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 + + +class EmailCodeAccountDeletionRateLimitExceededError(BaseHTTPException): + error_code = "email_code_account_deletion_rate_limit_exceeded" + description = "Too many account deletion emails have been sent. Please try again in 5 minutes." + code = 429 diff --git a/api/controllers/service_api/auth/forgot_password.py b/api/controllers/service_api/auth/forgot_password.py new file mode 100644 index 0000000000..6a49e41f1e --- /dev/null +++ b/api/controllers/service_api/auth/forgot_password.py @@ -0,0 +1,132 @@ +import base64 +import secrets + +from flask import request +from flask_restful import Resource, reqparse # type: ignore + +from constants.languages import languages +from controllers.service_api import api +from controllers.service_api.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError, PasswordMismatchError +from controllers.service_api.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError +from events.tenant_event import tenant_was_created +from extensions.ext_database import db +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, TenantService +from services.errors.account import AccountRegisterError +from services.errors.workspace import WorkSpaceNotAllowedCreateError +from services.feature_service import FeatureService + + +class ForgotPasswordSendEmailApi(Resource): + 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 = 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 AccountNotFound() + else: + token = AccountService.send_reset_password_email(account=account, email=args["email"], language=language) + + return {"result": "success", "data": token} + + +class ForgotPasswordCheckApi(Resource): + 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() + + user_email = args["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): + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + parser.add_argument("new_password", type=valid_password, required=True, nullable=False, location="json") + parser.add_argument("password_confirm", type=valid_password, required=True, nullable=False, location="json") + args = parser.parse_args() + + new_password = args["new_password"] + password_confirm = args["password_confirm"] + + if str(new_password).strip() != str(password_confirm).strip(): + raise PasswordMismatchError() + + token = args["token"] + reset_data = AccountService.get_reset_password_data(token) + + if reset_data is None: + raise InvalidTokenError() + + AccountService.revoke_reset_password_token(token) + + salt = secrets.token_bytes(16) + base64_salt = base64.b64encode(salt).decode() + + password_hashed = hash_password(new_password, salt) + base64_password_hashed = base64.b64encode(password_hashed).decode() + + account = Account.query.filter_by(email=reset_data.get("email")).first() + 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 + except AccountRegisterError as are: + raise AccountInFreezeError() + + return {"result": "success"} + + +api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password") +api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity") +api.add_resource(ForgotPasswordResetApi, "/forgot-password/resets") diff --git a/api/controllers/service_api/auth/login.py b/api/controllers/service_api/auth/login.py new file mode 100644 index 0000000000..ea981e0153 --- /dev/null +++ b/api/controllers/service_api/auth/login.py @@ -0,0 +1,363 @@ +from typing import cast + +import flask_login # type: ignore +from flask import request +from flask_restful import Resource, reqparse # type: ignore + +import services +from configs import dify_config +from constants.languages import languages +from controllers.service_api import api +from controllers.service_api.auth.error import ( + EmailCodeError, + EmailOrPasswordMismatchError, + EmailPasswordLoginLimitError, + InvalidEmailError, + InvalidTokenError, +) +from controllers.service_api.error import ( + AccountBannedError, + AccountInFreezeError, + AccountNotFound, + EmailSendIpLimitError, + NotAllowedCreateWorkspace, +) +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, RegisterService, TenantService +from services.billing_service import BillingService +from services.errors.account import AccountRegisterError +from services.errors.workspace import WorkSpaceNotAllowedCreateError +from services.feature_service import FeatureService + + +# TODO: copy as a separate auth service api +class LoginApi(Resource): + """Resource for user login.""" + + def post(self): + """Authenticate user and login. + --- + summary: User Login + description: Authenticate user with the provided email, password and optional invite token. + parameters: + - in: body + name: email + required: true + type: string + description: The user's email. + - in: body + name: password + required: true + type: string + description: The user's password. + - in: body + name: remember_me + required: false + type: boolean + description: Flag to indicate persistent login. + - in: body + name: invite_token + required: false + type: string + description: Invitation token if available. + - in: body + name: language + required: false + type: string + description: Interface language for response. + responses: + 200: + description: Success, returns token pair data. + 401: + description: Login failed due to invalid credentials or rate limits. + """ + parser = reqparse.RequestParser() + 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() + + if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(args["email"]): + raise AccountInFreezeError() + + is_login_error_rate_limit = AccountService.is_login_error_rate_limit(args["email"]) + if is_login_error_rate_limit: + raise EmailPasswordLoginLimitError() + + 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 AccountNotFound() + # SELF_HOSTED only have one workspace + tenants = TenantService.get_join_tenants(account) + if len(tenants) == 0: + return { + "result": "fail", + "data": "workspace not found, please contact system admin to invite you to join in a workspace", + } + + 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()} + + +class LogoutApi(Resource): + def get(self): + """Logout user. + --- + summary: Logout User + description: Logs out the authenticated user and invalidates the session. + responses: + 200: + description: Successfully logged out. + """ + 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 ResetPasswordSendEmailApi(Resource): + def post(self): + """Send reset password email. + --- + summary: Send Reset Password Email + description: Sends a reset password email to the provided email address. + parameters: + - in: body + name: email + required: true + type: string + description: The user's email. + - in: body + name: language + required: false + type: string + description: Preferred language for the email. + responses: + 200: + description: Successfully sent the reset email with token data. + 404: + description: Account not found. + """ + 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() + + if args["language"] is not None and args["language"] == "zh-Hans": + language = "zh-Hans" + else: + language = "en-US" + try: + account = AccountService.get_user_through_email(args["email"]) + except AccountRegisterError as are: + raise AccountInFreezeError() + 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 AccountNotFound() + else: + token = AccountService.send_reset_password_email(account=account, language=language) + + return {"result": "success", "data": token} + + +class EmailCodeLoginSendEmailApi(Resource): + def post(self): + """Send email code for login. + --- + summary: Email Code Login Email Sending + description: Sends an email with a verification code for login. + parameters: + - in: body + name: email + required: true + type: string + description: The user's email. + - in: body + name: language + required: false + type: string + description: Preferred language for the email. + responses: + 200: + description: Successfully sent the email code along with token data. + 429: + description: Too many requests, IP limit reached. + 404: + description: Account not found. + """ + 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" + try: + account = AccountService.get_user_through_email(args["email"]) + except AccountRegisterError as are: + raise AccountInFreezeError() + + 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 AccountNotFound() + else: + token = AccountService.send_email_code_login_email(account=account, language=language) + + return {"result": "success", "data": token} + + +class EmailCodeLoginApi(Resource): + def post(self): + """Login using email code. + --- + summary: Email Code Login + description: Allows the user to login using a verification code and token sent via email. + parameters: + - in: body + name: email + required: true + type: string + description: The user's email. + - in: body + name: code + required: true + type: string + description: The verification code sent to the email. + - in: body + name: token + required: true + type: string + description: The token associated with the email code login. + responses: + 200: + description: Successfully logged in, returns token pair data. + 400: + description: Invalid token, email or code. + """ + 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"]) + try: + account = AccountService.get_user_through_email(user_email) + except AccountRegisterError as are: + raise AccountInFreezeError() + 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 NotAllowedCreateWorkspace() + except AccountRegisterError as are: + raise AccountInFreezeError() + 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): + def post(self): + """Refresh authentication token. + --- + summary: Refresh Token + description: Refreshes an access token using a valid refresh token. + parameters: + - in: body + name: refresh_token + required: true + type: string + description: The refresh token provided in the request. + responses: + 200: + description: Successfully refreshed token, returns new token pair. + 401: + description: Failed to refresh the token due to invalid or expired refresh token. + """ + parser = reqparse.RequestParser() + parser.add_argument("refresh_token", type=str, required=True, location="json") + args = parser.parse_args() + + try: + new_token_pair = AccountService.refresh_token(args["refresh_token"]) + return {"result": "success", "data": new_token_pair.model_dump()} + except Exception as e: + return {"result": "fail", "data": str(e)}, 401 + + +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") diff --git a/api/controllers/service_api/error.py b/api/controllers/service_api/error.py new file mode 100644 index 0000000000..ee87138a44 --- /dev/null +++ b/api/controllers/service_api/error.py @@ -0,0 +1,103 @@ +from libs.exception import BaseHTTPException + + +class AlreadySetupError(BaseHTTPException): + error_code = "already_setup" + description = "Dify has been successfully installed. Please refresh the page or return to the dashboard homepage." + code = 403 + + +class NotSetupError(BaseHTTPException): + error_code = "not_setup" + description = ( + "Dify has not been initialized and installed yet. " + "Please proceed with the initialization and installation process first." + ) + code = 401 + + +class NotInitValidateError(BaseHTTPException): + error_code = "not_init_validated" + description = "Init validation has not been completed yet. Please proceed with the init validation process first." + code = 401 + + +class InitValidateFailedError(BaseHTTPException): + error_code = "init_validate_failed" + description = "Init validation failed. Please check the password and try again." + code = 401 + + +class AccountNotLinkTenantError(BaseHTTPException): + error_code = "account_not_link_tenant" + description = "Account not link tenant." + code = 403 + + +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 = "not_allowed_create_workspace" + 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 AccountNotFound(BaseHTTPException): + error_code = "account_not_found" + 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 + + +class FileTooLargeError(BaseHTTPException): + error_code = "file_too_large" + description = "File size exceeded. {message}" + code = 413 + + +class UnsupportedFileTypeError(BaseHTTPException): + error_code = "unsupported_file_type" + description = "File type not allowed." + code = 415 + + +class TooManyFilesError(BaseHTTPException): + error_code = "too_many_files" + description = "Only one file is allowed." + code = 400 + + +class NoFileUploadedError(BaseHTTPException): + error_code = "no_file_uploaded" + description = "Please upload your file." + code = 400 + + +class UnauthorizedAndForceLogout(BaseHTTPException): + error_code = "unauthorized_and_force_logout" + description = "Unauthorized and force logout." + code = 401 + + +class AccountInFreezeError(BaseHTTPException): + error_code = "account_in_freeze" + code = 400 + description = ( + "This email account has been deleted within the past 30 days" + "and is temporarily unavailable for new account registration." + ) diff --git a/api/extensions/ext_swagger.py b/api/extensions/ext_swagger.py index 9d68d971fb..46efa522fb 100644 --- a/api/extensions/ext_swagger.py +++ b/api/extensions/ext_swagger.py @@ -5,4 +5,9 @@ def init_app(app: DifyApp): from flasgger import Swagger - Swagger(app) \ No newline at end of file + app.config['SWAGGER'] = { + 'title': 'API Docs', + 'uiversion': 3 + } + + Swagger(app)