Skip to content

Commit df5b4ff

Browse files
committed
Merge branch 'refs/heads/feat/new-login' into deploy/dev
* refs/heads/feat/new-login: fix: email code login redirect feat: add system future config feat: add reset password api feat: update register logic feat: add email code login feat: add email templates for login and reset password
2 parents 175ca68 + 83bf1c9 commit df5b4ff

15 files changed

+615
-232
lines changed

api/.env.example

+8
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,11 @@ POSITION_TOOL_EXCLUDES=
277277
POSITION_PROVIDER_PINS=
278278
POSITION_PROVIDER_INCLUDES=
279279
POSITION_PROVIDER_EXCLUDES=
280+
281+
# Login
282+
ENABLE_EMAIL_CODE_LOGIN=
283+
ENABLE_EMAIL_PASSWORD_LOGIN=
284+
ENABLE_SOCIAL_OAUTH_LOGIN=
285+
EMAIL_CODE_LOGIN_TOKEN_EXPIRY_HOURS=1/12
286+
ALLOW_REGISTER=true
287+
ALLOW_CREATE_WORKSPACE=true

api/configs/feature/__init__.py

+38-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
from typing import Optional
22

3-
from pydantic import AliasChoices, Field, HttpUrl, NegativeInt, NonNegativeInt, PositiveInt, computed_field
3+
from pydantic import (
4+
AliasChoices,
5+
Field,
6+
HttpUrl,
7+
NegativeInt,
8+
NonNegativeInt,
9+
PositiveFloat,
10+
PositiveInt,
11+
computed_field,
12+
)
413
from pydantic_settings import BaseSettings
514

615
from configs.feature.hosted_service import HostedServiceConfig
@@ -607,6 +616,33 @@ def POSITION_TOOL_EXCLUDES_SET(self) -> set[str]:
607616
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
608617

609618

619+
class LoginConfig(BaseSettings):
620+
ENABLE_EMAIL_CODE_LOGIN: bool = Field(
621+
description="whether to enable email code login",
622+
default=True,
623+
)
624+
ENABLE_EMAIL_PASSWORD_LOGIN: bool = Field(
625+
description="whether to enable email password login",
626+
default=True,
627+
)
628+
ENABLE_SOCIAL_OAUTH_LOGIN: bool = Field(
629+
description="whether to enable github/google oauth login",
630+
default=True,
631+
)
632+
EMAIL_CODE_LOGIN_TOKEN_EXPIRY_HOURS: PositiveFloat = Field(
633+
description="expiry time in hours for email code login token",
634+
default=1 / 12,
635+
)
636+
ALLOW_REGISTER: bool = Field(
637+
description="whether to enable register",
638+
default=True,
639+
)
640+
ALLOW_CREATE_WORKSPACE: bool = Field(
641+
description="whether to enable create workspace",
642+
default=True,
643+
)
644+
645+
610646
class FeatureConfig(
611647
# place the configs in alphabet order
612648
AppExecutionConfig,
@@ -632,6 +668,7 @@ class FeatureConfig(
632668
WorkflowConfig,
633669
WorkspaceConfig,
634670
PositionConfig,
671+
LoginConfig,
635672
# hosted services config
636673
HostedServiceConfig,
637674
CeleryBeatConfig,

api/controllers/console/auth/error.py

+6
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,9 @@ class PasswordResetRateLimitExceededError(BaseHTTPException):
2929
error_code = "password_reset_rate_limit_exceeded"
3030
description = "Password reset rate limit exceeded. Try again later."
3131
code = 429
32+
33+
34+
class EmailCodeError(BaseHTTPException):
35+
error_code = "email_code_error"
36+
description = "Email code is invalid or expired."
37+
code = 400

api/controllers/console/auth/forgot_password.py

+45-25
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@
22
import logging
33
import secrets
44

5+
from flask import request
56
from flask_restful import Resource, reqparse
67

8+
from configs import dify_config
9+
from constants.languages import languages
710
from controllers.console import api
811
from controllers.console.auth.error import (
12+
EmailCodeError,
913
InvalidEmailError,
1014
InvalidTokenError,
1115
PasswordMismatchError,
1216
PasswordResetRateLimitExceededError,
1317
)
1418
from controllers.console.setup import setup_required
1519
from extensions.ext_database import db
16-
from libs.helper import email as email_validate
20+
from libs.helper import email, get_remote_ip
1721
from libs.password import hash_password, valid_password
1822
from models.account import Account
1923
from services.account_service import AccountService
@@ -24,42 +28,48 @@ class ForgotPasswordSendEmailApi(Resource):
2428
@setup_required
2529
def post(self):
2630
parser = reqparse.RequestParser()
27-
parser.add_argument("email", type=str, required=True, location="json")
31+
parser.add_argument("email", type=email, required=True, location="json")
2832
args = parser.parse_args()
2933

30-
email = args["email"]
31-
32-
if not email_validate(email):
33-
raise InvalidEmailError()
34-
35-
account = Account.query.filter_by(email=email).first()
36-
37-
if account:
34+
account = Account.query.filter_by(email=args["email"]).first()
35+
token = None
36+
if account is None:
37+
if dify_config.ALLOW_REGISTER:
38+
token = AccountService.send_reset_password_email(email=args["email"])
39+
else:
40+
raise InvalidEmailError()
41+
elif account:
3842
try:
39-
AccountService.send_reset_password_email(account=account)
43+
token = AccountService.send_reset_password_email(account=account, email=args["email"])
4044
except RateLimitExceededError:
41-
logging.warning(f"Rate limit exceeded for email: {account.email}")
45+
logging.warning(f"Rate limit exceeded for email: {args["email"]}")
4246
raise PasswordResetRateLimitExceededError()
43-
else:
44-
# Return success to avoid revealing email registration status
45-
logging.warning(f"Attempt to reset password for unregistered email: {email}")
4647

47-
return {"result": "success"}
48+
return {"result": "success", "data": token}
4849

4950

5051
class ForgotPasswordCheckApi(Resource):
5152
@setup_required
5253
def post(self):
5354
parser = reqparse.RequestParser()
55+
parser.add_argument("email", type=str, required=True, location="json")
56+
parser.add_argument("code", type=str, required=True, location="json")
5457
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
5558
args = parser.parse_args()
56-
token = args["token"]
5759

58-
reset_data = AccountService.get_reset_password_data(token)
60+
user_email = args["email"]
5961

60-
if reset_data is None:
61-
return {"is_valid": False, "email": None}
62-
return {"is_valid": True, "email": reset_data.get("email")}
62+
token_data = AccountService.get_reset_password_data(args["token"])
63+
if token_data is None:
64+
raise InvalidTokenError()
65+
66+
if user_email != token_data.get("email"):
67+
raise InvalidEmailError()
68+
69+
if args["code"] != token_data.get("code"):
70+
raise EmailCodeError()
71+
72+
return {"is_valid": True, "email": token_data.get("email")}
6373

6474

6575
class ForgotPasswordResetApi(Resource):
@@ -92,11 +102,21 @@ def post(self):
92102
base64_password_hashed = base64.b64encode(password_hashed).decode()
93103

94104
account = Account.query.filter_by(email=reset_data.get("email")).first()
95-
account.password = base64_password_hashed
96-
account.password_salt = base64_salt
97-
db.session.commit()
105+
if account:
106+
account.password = base64_password_hashed
107+
account.password_salt = base64_salt
108+
db.session.commit()
109+
else:
110+
account = AccountService.create_user_through_env(
111+
email=reset_data.get("email"),
112+
name=reset_data.get("email"),
113+
password=password_confirm,
114+
interface_language=languages[0],
115+
)
116+
117+
token = AccountService.login(account, ip_address=get_remote_ip(request))
98118

99-
return {"result": "success"}
119+
return {"result": "success", "data": token}
100120

101121

102122
api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password")

api/controllers/console/auth/login.py

+80-51
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
from typing import cast
22

33
import flask_login
4-
from flask import request
4+
from flask import redirect, request
55
from flask_restful import Resource, reqparse
66

77
import services
8+
from configs import dify_config
9+
from constants.languages import languages
810
from controllers.console import api
11+
from controllers.console.auth.error import (
12+
EmailCodeError,
13+
InvalidEmailError,
14+
InvalidTokenError,
15+
)
916
from controllers.console.setup import setup_required
1017
from libs.helper import email, get_remote_ip
1118
from libs.password import valid_password
@@ -25,12 +32,14 @@ def post(self):
2532
parser.add_argument("remember_me", type=bool, required=False, default=False, location="json")
2633
args = parser.parse_args()
2734

28-
# todo: Verify the recaptcha
29-
3035
try:
3136
account = AccountService.authenticate(args["email"], args["password"])
3237
except services.errors.account.AccountLoginError as e:
33-
return {"code": "unauthorized", "message": str(e)}, 401
38+
if dify_config.ALLOW_REGISTER:
39+
token = AccountService.send_reset_password_email(email=args["email"])
40+
return redirect(f"{dify_config.CONSOLE_WEB_URL}/reset-password?token={token}")
41+
else:
42+
return {"code": "unauthorized", "message": str(e)}, 401
3443

3544
# SELF_HOSTED only have one workspace
3645
tenants = TenantService.get_join_tenants(account)
@@ -55,56 +64,76 @@ def get(self):
5564
return {"result": "success"}
5665

5766

58-
class ResetPasswordApi(Resource):
67+
class ResetPasswordSendEmailApi(Resource):
5968
@setup_required
60-
def get(self):
61-
# parser = reqparse.RequestParser()
62-
# parser.add_argument('email', type=email, required=True, location='json')
63-
# args = parser.parse_args()
64-
65-
# import mailchimp_transactional as MailchimpTransactional
66-
# from mailchimp_transactional.api_client import ApiClientError
67-
68-
# account = {'email': args['email']}
69-
# account = AccountService.get_by_email(args['email'])
70-
# if account is None:
71-
# raise ValueError('Email not found')
72-
# new_password = AccountService.generate_password()
73-
# AccountService.update_password(account, new_password)
74-
75-
# todo: Send email
76-
# MAILCHIMP_API_KEY = dify_config.MAILCHIMP_TRANSACTIONAL_API_KEY
77-
# mailchimp = MailchimpTransactional(MAILCHIMP_API_KEY)
78-
79-
# message = {
80-
# 'from_email': '[email protected]',
81-
# 'to': [{'email': account['email']}],
82-
# 'subject': 'Reset your Dify password',
83-
# 'html': """
84-
# <p>Dear User,</p>
85-
# <p>The Dify team has generated a new password for you, details as follows:</p>
86-
# <p><strong>{new_password}</strong></p>
87-
# <p>Please change your password to log in as soon as possible.</p>
88-
# <p>Regards,</p>
89-
# <p>The Dify Team</p>
90-
# """
91-
# }
92-
93-
# response = mailchimp.messages.send({
94-
# 'message': message,
95-
# # required for transactional email
96-
# ' settings': {
97-
# 'sandbox_mode': dify_config.MAILCHIMP_SANDBOX_MODE,
98-
# },
99-
# })
100-
101-
# Check if MSG was sent
102-
# if response.status_code != 200:
103-
# # handle error
104-
# pass
69+
def post(self):
70+
parser = reqparse.RequestParser()
71+
parser.add_argument("email", type=email, required=True, location="json")
72+
args = parser.parse_args()
10573

106-
return {"result": "success"}
74+
account = AccountService.get_user_through_email(args["email"])
75+
if account is None:
76+
if dify_config.ALLOW_REGISTER:
77+
token = AccountService.send_reset_password_email(email=args["email"])
78+
else:
79+
raise InvalidEmailError()
80+
else:
81+
token = AccountService.send_reset_password_email(account=account)
82+
83+
return {"result": "success", "data": token}
84+
85+
86+
class EmailCodeLoginSendEmailApi(Resource):
87+
@setup_required
88+
def post(self):
89+
parser = reqparse.RequestParser()
90+
parser.add_argument("email", type=email, required=True, location="json")
91+
args = parser.parse_args()
92+
93+
account = AccountService.get_user_through_email(args["email"])
94+
if account is None:
95+
token = AccountService.send_email_code_login_email(email=args["email"])
96+
else:
97+
token = AccountService.send_email_code_login_email(account=account)
98+
99+
return {"result": "success", "data": token}
100+
101+
102+
class EmailCodeLoginApi(Resource):
103+
@setup_required
104+
def post(self):
105+
parser = reqparse.RequestParser()
106+
parser.add_argument("email", type=str, required=True, location="json")
107+
parser.add_argument("code", type=str, required=True, location="json")
108+
parser.add_argument("token", type=str, required=True, location="json")
109+
args = parser.parse_args()
110+
111+
user_email = args["email"]
112+
113+
token_data = AccountService.get_email_code_login_data(args["token"])
114+
if token_data is None:
115+
raise InvalidTokenError()
116+
117+
if token_data["email"] != args["email"]:
118+
raise InvalidEmailError()
119+
120+
if token_data["code"] != args["code"]:
121+
raise EmailCodeError()
122+
123+
AccountService.revoke_email_code_login_token(args["token"])
124+
account = AccountService.get_user_through_email(user_email)
125+
if account is None:
126+
account = AccountService.create_user_through_env(
127+
email=user_email, name=user_email, interface_language=languages[0]
128+
)
129+
130+
token = AccountService.login(account, ip_address=get_remote_ip(request))
131+
132+
return {"result": "success", "data": token}
107133

108134

109135
api.add_resource(LoginApi, "/login")
110136
api.add_resource(LogoutApi, "/logout")
137+
api.add_resource(EmailCodeLoginSendEmailApi, "/email-code-login")
138+
api.add_resource(EmailCodeLoginApi, "/email-code-login/validity")
139+
api.add_resource(ResetPasswordSendEmailApi, "/reset-password")

0 commit comments

Comments
 (0)