mirror of https://github.com/langgenius/dify.git
refactor: replace localStorage with HTTP-only cookies for auth tokens (#24365)
Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com> Signed-off-by: kenwoodjw <blackxin55+@gmail.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yunlu Wen <wylswz@163.com> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: GareArc <chen4851@purdue.edu> Co-authored-by: NFish <douxc512@gmail.com> Co-authored-by: Davide Delbianco <davide.delbianco@outlook.com> Co-authored-by: minglu7 <1347866672@qq.com> Co-authored-by: Ponder <ruan.lj@foxmail.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: heyszt <270985384@qq.com> Co-authored-by: Asuka Minato <i@asukaminato.eu.org> Co-authored-by: Guangdong Liu <liugddx@gmail.com> Co-authored-by: Eric Guo <eric.guocz@gmail.com> Co-authored-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Co-authored-by: XlKsyt <caixuesen@outlook.com> Co-authored-by: Dhruv Gorasiya <80987415+DhruvGorasiya@users.noreply.github.com> Co-authored-by: crazywoola <427733928@qq.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com> Co-authored-by: hj24 <mambahj24@gmail.com> Co-authored-by: GuanMu <ballmanjq@gmail.com> Co-authored-by: 非法操作 <hjlarry@163.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Tonlo <123lzs123@gmail.com> Co-authored-by: Yusuke Yamada <yamachu.dev@gmail.com> Co-authored-by: Novice <novice12185727@gmail.com> Co-authored-by: kenwoodjw <blackxin55+@gmail.com> Co-authored-by: Ademílson Tonato <ademilsonft@outlook.com> Co-authored-by: znn <jubinkumarsoni@gmail.com> Co-authored-by: yangzheli <43645580+yangzheli@users.noreply.github.com>
This commit is contained in:
parent
141ca8904a
commit
9a5f214623
|
|
@ -55,3 +55,12 @@ else:
|
|||
"properties",
|
||||
}
|
||||
DOCUMENT_EXTENSIONS: set[str] = convert_to_lower_and_upper_set(_doc_extensions)
|
||||
|
||||
COOKIE_NAME_ACCESS_TOKEN = "access_token"
|
||||
COOKIE_NAME_REFRESH_TOKEN = "refresh_token"
|
||||
COOKIE_NAME_PASSPORT = "passport"
|
||||
COOKIE_NAME_CSRF_TOKEN = "csrf_token"
|
||||
|
||||
HEADER_NAME_CSRF_TOKEN = "X-CSRF-Token"
|
||||
HEADER_NAME_APP_CODE = "X-App-Code"
|
||||
HEADER_NAME_PASSPORT = "X-App-Passport"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from constants.languages import supported_language
|
|||
from controllers.console import api, console_ns
|
||||
from controllers.console.wraps import only_edition_cloud
|
||||
from extensions.ext_database import db
|
||||
from libs.token import extract_access_token
|
||||
from models.model import App, InstalledApp, RecommendedApp
|
||||
|
||||
|
||||
|
|
@ -24,19 +25,9 @@ def admin_required(view: Callable[P, R]):
|
|||
if not dify_config.ADMIN_API_KEY:
|
||||
raise Unauthorized("API key is invalid.")
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header is None:
|
||||
auth_token = extract_access_token(request)
|
||||
if not auth_token:
|
||||
raise Unauthorized("Authorization header is missing.")
|
||||
|
||||
if " " not in auth_header:
|
||||
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
|
||||
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
auth_scheme = auth_scheme.lower()
|
||||
|
||||
if auth_scheme != "bearer":
|
||||
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
|
||||
|
||||
if auth_token != dify_config.ADMIN_API_KEY:
|
||||
raise Unauthorized("API key is invalid.")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import flask_login
|
||||
from flask import request
|
||||
from flask import make_response, request
|
||||
from flask_restx import Resource, reqparse
|
||||
|
||||
import services
|
||||
|
|
@ -25,6 +25,16 @@ from controllers.console.wraps import email_password_login_enabled, setup_requir
|
|||
from events.tenant_event import tenant_was_created
|
||||
from libs.helper import email, extract_remote_ip
|
||||
from libs.login import current_account_with_tenant
|
||||
from libs.token import (
|
||||
clear_access_token_from_cookie,
|
||||
clear_csrf_token_from_cookie,
|
||||
clear_refresh_token_from_cookie,
|
||||
extract_access_token,
|
||||
extract_csrf_token,
|
||||
set_access_token_to_cookie,
|
||||
set_csrf_token_to_cookie,
|
||||
set_refresh_token_to_cookie,
|
||||
)
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import AccountRegisterError
|
||||
|
|
@ -89,20 +99,36 @@ 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()}
|
||||
|
||||
# Create response with cookies instead of returning tokens in body
|
||||
response = make_response({"result": "success"})
|
||||
|
||||
set_access_token_to_cookie(request, response, token_pair.access_token)
|
||||
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
|
||||
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/logout")
|
||||
class LogoutApi(Resource):
|
||||
@setup_required
|
||||
def get(self):
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
account = current_user
|
||||
if isinstance(account, flask_login.AnonymousUserMixin):
|
||||
return {"result": "success"}
|
||||
AccountService.logout(account=account)
|
||||
flask_login.logout_user()
|
||||
return {"result": "success"}
|
||||
response = make_response({"result": "success"})
|
||||
else:
|
||||
AccountService.logout(account=account)
|
||||
flask_login.logout_user()
|
||||
response = make_response({"result": "success"})
|
||||
|
||||
# Clear cookies on logout
|
||||
clear_access_token_from_cookie(response)
|
||||
clear_refresh_token_from_cookie(response)
|
||||
clear_csrf_token_from_cookie(response)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/reset-password")
|
||||
|
|
@ -227,17 +253,46 @@ class EmailCodeLoginApi(Resource):
|
|||
raise WorkspacesLimitExceeded()
|
||||
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()}
|
||||
|
||||
# Create response with cookies instead of returning tokens in body
|
||||
response = make_response({"result": "success"})
|
||||
|
||||
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
|
||||
# Set HTTP-only secure cookies for tokens
|
||||
set_access_token_to_cookie(request, response, token_pair.access_token)
|
||||
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/refresh-token")
|
||||
class RefreshTokenApi(Resource):
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser().add_argument("refresh_token", type=str, required=True, location="json")
|
||||
args = parser.parse_args()
|
||||
# Get refresh token from cookie instead of request body
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
|
||||
if not refresh_token:
|
||||
return {"result": "fail", "message": "No refresh token provided"}, 401
|
||||
|
||||
try:
|
||||
new_token_pair = AccountService.refresh_token(args["refresh_token"])
|
||||
return {"result": "success", "data": new_token_pair.model_dump()}
|
||||
new_token_pair = AccountService.refresh_token(refresh_token)
|
||||
|
||||
# Create response with new cookies
|
||||
response = make_response({"result": "success"})
|
||||
|
||||
# Update cookies with new tokens
|
||||
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
|
||||
set_access_token_to_cookie(request, response, new_token_pair.access_token)
|
||||
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
|
||||
return response
|
||||
except Exception as e:
|
||||
return {"result": "fail", "data": str(e)}, 401
|
||||
return {"result": "fail", "message": str(e)}, 401
|
||||
|
||||
|
||||
# this api helps frontend to check whether user is authenticated
|
||||
# TODO: remove in the future. frontend should redirect to login page by catching 401 status
|
||||
@console_ns.route("/login/status")
|
||||
class LoginStatus(Resource):
|
||||
def get(self):
|
||||
token = extract_access_token(request)
|
||||
csrf_token = extract_csrf_token(request)
|
||||
return {"logged_in": bool(token) and bool(csrf_token)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ from extensions.ext_database import db
|
|||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import extract_remote_ip
|
||||
from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo
|
||||
from libs.token import (
|
||||
set_access_token_to_cookie,
|
||||
set_csrf_token_to_cookie,
|
||||
set_refresh_token_to_cookie,
|
||||
)
|
||||
from models import Account, AccountStatus
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
|
|
@ -152,9 +157,12 @@ class OAuthCallback(Resource):
|
|||
ip_address=extract_remote_ip(request),
|
||||
)
|
||||
|
||||
return redirect(
|
||||
f"{dify_config.CONSOLE_WEB_URL}?access_token={token_pair.access_token}&refresh_token={token_pair.refresh_token}"
|
||||
)
|
||||
response = redirect(f"{dify_config.CONSOLE_WEB_URL}")
|
||||
|
||||
set_access_token_to_cookie(request, response, token_pair.access_token)
|
||||
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
|
||||
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
|
||||
return response
|
||||
|
||||
|
||||
def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> Account | None:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from libs.datetime_utils import naive_utc_now
|
|||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import App, InstalledApp, RecommendedApp
|
||||
from services.account_service import TenantService
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
|
@ -67,31 +66,26 @@ class InstalledAppsListApi(Resource):
|
|||
|
||||
# Pre-filter out apps without setting or with sso_verified
|
||||
filtered_installed_apps = []
|
||||
app_id_to_app_code = {}
|
||||
|
||||
for installed_app in installed_app_list:
|
||||
app_id = installed_app["app"].id
|
||||
webapp_setting = webapp_settings.get(app_id)
|
||||
if not webapp_setting or webapp_setting.access_mode == "sso_verified":
|
||||
continue
|
||||
app_code = AppService.get_app_code_by_id(str(app_id))
|
||||
app_id_to_app_code[app_id] = app_code
|
||||
filtered_installed_apps.append(installed_app)
|
||||
|
||||
app_codes = list(app_id_to_app_code.values())
|
||||
|
||||
# Batch permission check
|
||||
app_ids = [installed_app["app"].id for installed_app in filtered_installed_apps]
|
||||
permissions = EnterpriseService.WebAppAuth.batch_is_user_allowed_to_access_webapps(
|
||||
user_id=user_id,
|
||||
app_codes=app_codes,
|
||||
app_ids=app_ids,
|
||||
)
|
||||
|
||||
# Keep only allowed apps
|
||||
res = []
|
||||
for installed_app in filtered_installed_apps:
|
||||
app_id = installed_app["app"].id
|
||||
app_code = app_id_to_app_code[app_id]
|
||||
if permissions.get(app_code):
|
||||
if permissions.get(app_id):
|
||||
res.append(installed_app)
|
||||
|
||||
installed_app_list = res
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from controllers.console.wraps import account_initialization_required
|
|||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import InstalledApp
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
|
@ -56,10 +55,9 @@ def user_allowed_to_access_app(view: Callable[Concatenate[InstalledApp, P], R] |
|
|||
feature = FeatureService.get_system_features()
|
||||
if feature.webapp_auth.enabled:
|
||||
app_id = installed_app.app_id
|
||||
app_code = AppService.get_app_code_by_id(app_id)
|
||||
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
|
||||
user_id=str(current_user.id),
|
||||
app_code=app_code,
|
||||
app_id=app_id,
|
||||
)
|
||||
if not res:
|
||||
raise AppAccessDeniedError()
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ from flask import request
|
|||
from flask_restx import Resource, marshal_with, reqparse
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.common import fields
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import AppUnavailableError
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_webapp_passport
|
||||
from models.model import App, AppMode
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
|
|
@ -133,18 +135,19 @@ class AppWebAuthPermission(Resource):
|
|||
)
|
||||
def get(self):
|
||||
user_id = "visitor"
|
||||
app_code = request.headers.get(HEADER_NAME_APP_CODE)
|
||||
app_id = request.args.get("appId")
|
||||
if not app_id or not app_code:
|
||||
raise ValueError("appId must be provided")
|
||||
|
||||
require_permission_check = WebAppAuthService.is_app_require_permission_check(app_id=app_id)
|
||||
if not require_permission_check:
|
||||
return {"result": True}
|
||||
|
||||
try:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header is None:
|
||||
raise Unauthorized("Authorization header is missing.")
|
||||
if " " not in auth_header:
|
||||
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
|
||||
|
||||
auth_scheme, tk = auth_header.split(None, 1)
|
||||
auth_scheme = auth_scheme.lower()
|
||||
if auth_scheme != "bearer":
|
||||
raise Unauthorized("Authorization scheme must be 'Bearer'")
|
||||
|
||||
tk = extract_webapp_passport(app_code, request)
|
||||
if not tk:
|
||||
raise Unauthorized("Access token is missing.")
|
||||
decoded = PassportService().verify(tk)
|
||||
user_id = decoded.get("user_id", "visitor")
|
||||
except Unauthorized:
|
||||
|
|
@ -157,13 +160,7 @@ class AppWebAuthPermission(Resource):
|
|||
if not features.webapp_auth.enabled:
|
||||
return {"result": True}
|
||||
|
||||
parser = reqparse.RequestParser().add_argument("appId", type=str, required=True, location="args")
|
||||
args = parser.parse_args()
|
||||
|
||||
app_id = args["appId"]
|
||||
app_code = AppService.get_app_code_by_id(app_id)
|
||||
|
||||
res = True
|
||||
if WebAppAuthService.is_app_require_permission_check(app_id=app_id):
|
||||
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_code)
|
||||
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_id)
|
||||
return {"result": res}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from flask import make_response, request
|
||||
from flask_restx import Resource, reqparse
|
||||
from jwt import InvalidTokenError
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.console.auth.error import (
|
||||
AuthenticationFailedError,
|
||||
EmailCodeError,
|
||||
|
|
@ -10,9 +12,16 @@ from controllers.console.auth.error import (
|
|||
from controllers.console.error import AccountBannedError
|
||||
from controllers.console.wraps import only_edition_enterprise, setup_required
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.wraps import decode_jwt_token
|
||||
from libs.helper import email
|
||||
from libs.passport import PassportService
|
||||
from libs.password import valid_password
|
||||
from libs.token import (
|
||||
clear_access_token_from_cookie,
|
||||
extract_access_token,
|
||||
)
|
||||
from services.account_service import AccountService
|
||||
from services.app_service import AppService
|
||||
from services.webapp_auth_service import WebAppAuthService
|
||||
|
||||
|
||||
|
|
@ -52,17 +61,75 @@ class LoginApi(Resource):
|
|||
raise AuthenticationFailedError()
|
||||
|
||||
token = WebAppAuthService.login(account=account)
|
||||
return {"result": "success", "data": {"access_token": token}}
|
||||
response = make_response({"result": "success", "data": {"access_token": token}})
|
||||
# set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
|
||||
return response
|
||||
|
||||
|
||||
# class LogoutApi(Resource):
|
||||
# @setup_required
|
||||
# def get(self):
|
||||
# account = cast(Account, flask_login.current_user)
|
||||
# if isinstance(account, flask_login.AnonymousUserMixin):
|
||||
# return {"result": "success"}
|
||||
# flask_login.logout_user()
|
||||
# return {"result": "success"}
|
||||
# this api helps frontend to check whether user is authenticated
|
||||
# TODO: remove in the future. frontend should redirect to login page by catching 401 status
|
||||
@web_ns.route("/login/status")
|
||||
class LoginStatusApi(Resource):
|
||||
@setup_required
|
||||
@web_ns.doc("web_app_login_status")
|
||||
@web_ns.doc(description="Check login status")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Login status",
|
||||
401: "Login status",
|
||||
}
|
||||
)
|
||||
def get(self):
|
||||
app_code = request.args.get("app_code")
|
||||
token = extract_access_token(request)
|
||||
if not app_code:
|
||||
return {
|
||||
"logged_in": bool(token),
|
||||
"app_logged_in": False,
|
||||
}
|
||||
app_id = AppService.get_app_id_by_code(app_code)
|
||||
is_public = not dify_config.ENTERPRISE_ENABLED or not WebAppAuthService.is_app_require_permission_check(
|
||||
app_id=app_id
|
||||
)
|
||||
user_logged_in = False
|
||||
|
||||
if is_public:
|
||||
user_logged_in = True
|
||||
else:
|
||||
try:
|
||||
PassportService().verify(token=token)
|
||||
user_logged_in = True
|
||||
except Exception:
|
||||
user_logged_in = False
|
||||
|
||||
try:
|
||||
_ = decode_jwt_token(app_code=app_code)
|
||||
app_logged_in = True
|
||||
except Exception:
|
||||
app_logged_in = False
|
||||
|
||||
return {
|
||||
"logged_in": user_logged_in,
|
||||
"app_logged_in": app_logged_in,
|
||||
}
|
||||
|
||||
|
||||
@web_ns.route("/logout")
|
||||
class LogoutApi(Resource):
|
||||
@setup_required
|
||||
@web_ns.doc("web_app_logout")
|
||||
@web_ns.doc(description="Logout user from web application")
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Logout successful",
|
||||
}
|
||||
)
|
||||
def post(self):
|
||||
response = make_response({"result": "success"})
|
||||
# enterprise SSO sets same site to None in https deployment
|
||||
# so we need to logout by calling api
|
||||
clear_access_token_from_cookie(response, samesite="None")
|
||||
return response
|
||||
|
||||
|
||||
@web_ns.route("/email-code-login")
|
||||
|
|
@ -96,7 +163,6 @@ class EmailCodeLoginSendEmailApi(Resource):
|
|||
raise AuthenticationFailedError()
|
||||
else:
|
||||
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
|
||||
|
||||
return {"result": "success", "data": token}
|
||||
|
||||
|
||||
|
|
@ -142,4 +208,6 @@ class EmailCodeLoginApi(Resource):
|
|||
|
||||
token = WebAppAuthService.login(account=account)
|
||||
AccountService.reset_login_error_rate_limit(args["email"])
|
||||
return {"result": "success", "data": {"access_token": token}}
|
||||
response = make_response({"result": "success", "data": {"access_token": token}})
|
||||
# set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from flask import request
|
||||
from flask import make_response, request
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import func, select
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
|
||||
from configs import dify_config
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import WebAppAuthRequiredError
|
||||
from extensions.ext_database import db
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_access_token
|
||||
from models.model import App, EndUser, Site
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
|
||||
|
|
@ -32,15 +35,15 @@ class PassportResource(Resource):
|
|||
)
|
||||
def get(self):
|
||||
system_features = FeatureService.get_system_features()
|
||||
app_code = request.headers.get("X-App-Code")
|
||||
app_code = request.headers.get(HEADER_NAME_APP_CODE)
|
||||
user_id = request.args.get("user_id")
|
||||
web_app_access_token = request.args.get("web_app_access_token")
|
||||
access_token = extract_access_token(request)
|
||||
|
||||
if app_code is None:
|
||||
raise Unauthorized("X-App-Code header is missing.")
|
||||
|
||||
app_id = AppService.get_app_id_by_code(app_code)
|
||||
# exchange token for enterprise logined web user
|
||||
enterprise_user_decoded = decode_enterprise_webapp_user_id(web_app_access_token)
|
||||
enterprise_user_decoded = decode_enterprise_webapp_user_id(access_token)
|
||||
if enterprise_user_decoded:
|
||||
# a web user has already logged in, exchange a token for this app without redirecting to the login page
|
||||
return exchange_token_for_existing_web_user(
|
||||
|
|
@ -48,7 +51,7 @@ class PassportResource(Resource):
|
|||
)
|
||||
|
||||
if system_features.webapp_auth.enabled:
|
||||
app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code)
|
||||
app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id)
|
||||
if not app_settings or not app_settings.access_mode == "public":
|
||||
raise WebAppAuthRequiredError()
|
||||
|
||||
|
|
@ -99,9 +102,12 @@ class PassportResource(Resource):
|
|||
|
||||
tk = PassportService().issue(payload)
|
||||
|
||||
return {
|
||||
"access_token": tk,
|
||||
}
|
||||
response = make_response(
|
||||
{
|
||||
"access_token": tk,
|
||||
}
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def decode_enterprise_webapp_user_id(jwt_token: str | None):
|
||||
|
|
@ -189,9 +195,12 @@ def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded:
|
|||
"exp": exp,
|
||||
}
|
||||
token: str = PassportService().issue(payload)
|
||||
return {
|
||||
"access_token": token,
|
||||
}
|
||||
resp = make_response(
|
||||
{
|
||||
"access_token": token,
|
||||
}
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def _exchange_for_public_app_token(app_model, site, token_decoded):
|
||||
|
|
@ -224,9 +233,12 @@ def _exchange_for_public_app_token(app_model, site, token_decoded):
|
|||
|
||||
tk = PassportService().issue(payload)
|
||||
|
||||
return {
|
||||
"access_token": tk,
|
||||
}
|
||||
resp = make_response(
|
||||
{
|
||||
"access_token": tk,
|
||||
}
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def generate_session_id():
|
||||
|
|
|
|||
|
|
@ -9,10 +9,13 @@ from sqlalchemy import select
|
|||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
|
||||
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError
|
||||
from extensions.ext_database import db
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_webapp_passport
|
||||
from models.model import App, EndUser, Site
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.enterprise_service import EnterpriseService, WebAppSettings
|
||||
from services.feature_service import FeatureService
|
||||
from services.webapp_auth_service import WebAppAuthService
|
||||
|
|
@ -35,22 +38,14 @@ def validate_jwt_token(view: Callable[Concatenate[App, EndUser, P], R] | None =
|
|||
return decorator
|
||||
|
||||
|
||||
def decode_jwt_token():
|
||||
def decode_jwt_token(app_code: str | None = None):
|
||||
system_features = FeatureService.get_system_features()
|
||||
app_code = str(request.headers.get("X-App-Code"))
|
||||
if not app_code:
|
||||
app_code = str(request.headers.get(HEADER_NAME_APP_CODE))
|
||||
try:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header is None:
|
||||
raise Unauthorized("Authorization header is missing.")
|
||||
|
||||
if " " not in auth_header:
|
||||
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
|
||||
|
||||
auth_scheme, tk = auth_header.split(None, 1)
|
||||
auth_scheme = auth_scheme.lower()
|
||||
|
||||
if auth_scheme != "bearer":
|
||||
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
|
||||
tk = extract_webapp_passport(app_code, request)
|
||||
if not tk:
|
||||
raise Unauthorized("App token is missing.")
|
||||
decoded = PassportService().verify(tk)
|
||||
app_code = decoded.get("app_code")
|
||||
app_id = decoded.get("app_id")
|
||||
|
|
@ -72,7 +67,8 @@ def decode_jwt_token():
|
|||
app_web_auth_enabled = False
|
||||
webapp_settings = None
|
||||
if system_features.webapp_auth.enabled:
|
||||
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code)
|
||||
app_id = AppService.get_app_id_by_code(app_code)
|
||||
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
|
||||
if not webapp_settings:
|
||||
raise NotFound("Web app settings not found.")
|
||||
app_web_auth_enabled = webapp_settings.access_mode != "public"
|
||||
|
|
@ -87,8 +83,9 @@ def decode_jwt_token():
|
|||
if system_features.webapp_auth.enabled:
|
||||
if not app_code:
|
||||
raise Unauthorized("Please re-login to access the web app.")
|
||||
app_id = AppService.get_app_id_by_code(app_code)
|
||||
app_web_auth_enabled = (
|
||||
EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=str(app_code)).access_mode != "public"
|
||||
EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id).access_mode != "public"
|
||||
)
|
||||
if app_web_auth_enabled:
|
||||
raise WebAppAuthRequiredError()
|
||||
|
|
@ -129,7 +126,8 @@ def _validate_user_accessibility(
|
|||
raise WebAppAuthRequiredError("Web app settings not found.")
|
||||
|
||||
if WebAppAuthService.is_app_require_permission_check(access_mode=webapp_settings.access_mode):
|
||||
if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_code=app_code):
|
||||
app_id = AppService.get_app_id_by_code(app_code)
|
||||
if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_id):
|
||||
raise WebAppAuthAccessDeniedError()
|
||||
|
||||
auth_type = decoded.get("auth_type")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from configs import dify_config
|
||||
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN
|
||||
from dify_app import DifyApp
|
||||
|
||||
|
||||
|
|
@ -16,7 +17,7 @@ def init_app(app: DifyApp):
|
|||
|
||||
CORS(
|
||||
service_api_bp,
|
||||
allow_headers=["Content-Type", "Authorization", "X-App-Code"],
|
||||
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE],
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
)
|
||||
app.register_blueprint(service_api_bp)
|
||||
|
|
@ -25,7 +26,7 @@ def init_app(app: DifyApp):
|
|||
web_bp,
|
||||
resources={r"/*": {"origins": dify_config.WEB_API_CORS_ALLOW_ORIGINS}},
|
||||
supports_credentials=True,
|
||||
allow_headers=["Content-Type", "Authorization", "X-App-Code"],
|
||||
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN],
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
)
|
||||
|
|
@ -35,7 +36,7 @@ def init_app(app: DifyApp):
|
|||
console_app_bp,
|
||||
resources={r"/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}},
|
||||
supports_credentials=True,
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
allow_headers=["Content-Type", "Authorization", HEADER_NAME_CSRF_TOKEN],
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
)
|
||||
|
|
@ -43,7 +44,7 @@ def init_app(app: DifyApp):
|
|||
|
||||
CORS(
|
||||
files_bp,
|
||||
allow_headers=["Content-Type"],
|
||||
allow_headers=["Content-Type", HEADER_NAME_CSRF_TOKEN],
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
)
|
||||
app.register_blueprint(files_bp)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from configs import dify_config
|
|||
from dify_app import DifyApp
|
||||
from extensions.ext_database import db
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_access_token
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
from models.model import AppMCPServer, EndUser
|
||||
from services.account_service import AccountService
|
||||
|
|
@ -24,20 +25,10 @@ def load_user_from_request(request_from_flask_login):
|
|||
if dify_config.SWAGGER_UI_ENABLED and request.path.endswith((dify_config.SWAGGER_UI_PATH, "/swagger.json")):
|
||||
return None
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
auth_token: str | None = None
|
||||
if auth_header:
|
||||
if " " not in auth_header:
|
||||
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
|
||||
auth_scheme, auth_token = auth_header.split(maxsplit=1)
|
||||
auth_scheme = auth_scheme.lower()
|
||||
if auth_scheme != "bearer":
|
||||
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
|
||||
else:
|
||||
auth_token = request.args.get("_token")
|
||||
auth_token = extract_access_token(request)
|
||||
|
||||
# Check for admin API key authentication first
|
||||
if dify_config.ADMIN_API_KEY_ENABLE and auth_header:
|
||||
if dify_config.ADMIN_API_KEY_ENABLE and auth_token:
|
||||
admin_api_key = dify_config.ADMIN_API_KEY
|
||||
if admin_api_key and admin_api_key == auth_token:
|
||||
workspace_id = request.headers.get("X-WORKSPACE-ID")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ from werkzeug.exceptions import HTTPException
|
|||
from werkzeug.http import HTTP_STATUS_CODES
|
||||
|
||||
from configs import dify_config
|
||||
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN
|
||||
from core.errors.error import AppInvokeQuotaExceededError
|
||||
from libs.token import is_secure
|
||||
|
||||
|
||||
def http_status_message(code):
|
||||
|
|
@ -67,6 +69,19 @@ def register_external_error_handlers(api: Api):
|
|||
# If you need WWW-Authenticate for 401, add it to headers
|
||||
if status_code == 401:
|
||||
headers["WWW-Authenticate"] = 'Bearer realm="api"'
|
||||
# Check if this is a forced logout error - clear cookies
|
||||
error_code = getattr(e, "error_code", None)
|
||||
if error_code == "unauthorized_and_force_logout":
|
||||
# Add Set-Cookie headers to clear auth cookies
|
||||
|
||||
secure = is_secure()
|
||||
# response is not accessible, so we need to do it ugly
|
||||
common_part = "Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly"
|
||||
headers["Set-Cookie"] = [
|
||||
f'{COOKIE_NAME_ACCESS_TOKEN}=""; {common_part}{"; Secure" if secure else ""}; SameSite=Lax',
|
||||
f'{COOKIE_NAME_CSRF_TOKEN}=""; {common_part}{"; Secure" if secure else ""}; SameSite=Lax',
|
||||
f'{COOKIE_NAME_REFRESH_TOKEN}=""; {common_part}{"; Secure" if secure else ""}; SameSite=Lax',
|
||||
]
|
||||
return data, status_code, headers
|
||||
|
||||
_ = handle_http_exception
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from flask_login.config import EXEMPT_METHODS # type: ignore
|
|||
from werkzeug.local import LocalProxy
|
||||
|
||||
from configs import dify_config
|
||||
from libs.token import check_csrf_token
|
||||
from models import Account
|
||||
from models.model import EndUser
|
||||
|
||||
|
|
@ -73,6 +74,9 @@ def login_required(func: Callable[P, R]):
|
|||
pass
|
||||
elif current_user is not None and not current_user.is_authenticated:
|
||||
return current_app.login_manager.unauthorized() # type: ignore
|
||||
# we put csrf validation here for less conflicts
|
||||
# TODO: maybe find a better place for it.
|
||||
check_csrf_token(request, current_user.id)
|
||||
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||
|
||||
return decorated_view
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from flask import Request
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from configs import dify_config
|
||||
from constants import (
|
||||
COOKIE_NAME_ACCESS_TOKEN,
|
||||
COOKIE_NAME_CSRF_TOKEN,
|
||||
COOKIE_NAME_PASSPORT,
|
||||
COOKIE_NAME_REFRESH_TOKEN,
|
||||
HEADER_NAME_CSRF_TOKEN,
|
||||
HEADER_NAME_PASSPORT,
|
||||
)
|
||||
from libs.passport import PassportService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CSRF_WHITE_LIST = [
|
||||
re.compile(r"/console/api/apps/[a-f0-9-]+/workflows/draft"),
|
||||
]
|
||||
|
||||
|
||||
# server is behind a reverse proxy, so we need to check the url
|
||||
def is_secure() -> bool:
|
||||
return dify_config.CONSOLE_WEB_URL.startswith("https") and dify_config.CONSOLE_API_URL.startswith("https")
|
||||
|
||||
|
||||
def _real_cookie_name(cookie_name: str) -> str:
|
||||
if is_secure():
|
||||
return "__Host-" + cookie_name
|
||||
else:
|
||||
return cookie_name
|
||||
|
||||
|
||||
def _try_extract_from_header(request: Request) -> str | None:
|
||||
"""
|
||||
Try to extract access token from header
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header:
|
||||
if " " not in auth_header:
|
||||
return None
|
||||
else:
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
auth_scheme = auth_scheme.lower()
|
||||
if auth_scheme != "bearer":
|
||||
return None
|
||||
else:
|
||||
return auth_token
|
||||
return None
|
||||
|
||||
|
||||
def extract_csrf_token(request: Request) -> str | None:
|
||||
"""
|
||||
Try to extract CSRF token from header or cookie.
|
||||
"""
|
||||
return request.headers.get(HEADER_NAME_CSRF_TOKEN)
|
||||
|
||||
|
||||
def extract_csrf_token_from_cookie(request: Request) -> str | None:
|
||||
"""
|
||||
Try to extract CSRF token from cookie.
|
||||
"""
|
||||
return request.cookies.get(_real_cookie_name(COOKIE_NAME_CSRF_TOKEN))
|
||||
|
||||
|
||||
def extract_access_token(request: Request) -> str | None:
|
||||
"""
|
||||
Try to extract access token from cookie, header or params.
|
||||
|
||||
Access token is either for console session or webapp passport exchange.
|
||||
"""
|
||||
|
||||
def _try_extract_from_cookie(request: Request) -> str | None:
|
||||
return request.cookies.get(_real_cookie_name(COOKIE_NAME_ACCESS_TOKEN))
|
||||
|
||||
return _try_extract_from_cookie(request) or _try_extract_from_header(request)
|
||||
|
||||
|
||||
def extract_webapp_passport(app_code: str, request: Request) -> str | None:
|
||||
"""
|
||||
Try to extract app token from header or params.
|
||||
|
||||
Webapp access token (part of passport) is only used for webapp session.
|
||||
"""
|
||||
|
||||
def _try_extract_passport_token_from_cookie(request: Request) -> str | None:
|
||||
return request.cookies.get(_real_cookie_name(COOKIE_NAME_PASSPORT + "-" + app_code))
|
||||
|
||||
def _try_extract_passport_token_from_header(request: Request) -> str | None:
|
||||
return request.headers.get(HEADER_NAME_PASSPORT)
|
||||
|
||||
ret = _try_extract_passport_token_from_cookie(request) or _try_extract_passport_token_from_header(request)
|
||||
return ret
|
||||
|
||||
|
||||
def set_access_token_to_cookie(request: Request, response: Response, token: str, samesite: str = "Lax"):
|
||||
response.set_cookie(
|
||||
_real_cookie_name(COOKIE_NAME_ACCESS_TOKEN),
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=is_secure(),
|
||||
samesite=samesite,
|
||||
max_age=int(dify_config.ACCESS_TOKEN_EXPIRE_MINUTES * 60),
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def set_refresh_token_to_cookie(request: Request, response: Response, token: str):
|
||||
response.set_cookie(
|
||||
_real_cookie_name(COOKIE_NAME_REFRESH_TOKEN),
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=is_secure(),
|
||||
samesite="Lax",
|
||||
max_age=int(60 * 60 * 24 * dify_config.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def set_csrf_token_to_cookie(request: Request, response: Response, token: str):
|
||||
response.set_cookie(
|
||||
_real_cookie_name(COOKIE_NAME_CSRF_TOKEN),
|
||||
value=token,
|
||||
httponly=False,
|
||||
secure=is_secure(),
|
||||
samesite="Lax",
|
||||
max_age=int(60 * dify_config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _clear_cookie(
|
||||
response: Response,
|
||||
cookie_name: str,
|
||||
samesite: str = "Lax",
|
||||
http_only: bool = True,
|
||||
):
|
||||
response.set_cookie(
|
||||
_real_cookie_name(cookie_name),
|
||||
"",
|
||||
expires=0,
|
||||
path="/",
|
||||
secure=is_secure(),
|
||||
httponly=http_only,
|
||||
samesite=samesite,
|
||||
)
|
||||
|
||||
|
||||
def clear_access_token_from_cookie(response: Response, samesite: str = "Lax"):
|
||||
_clear_cookie(response, COOKIE_NAME_ACCESS_TOKEN, samesite)
|
||||
|
||||
|
||||
def clear_refresh_token_from_cookie(response: Response):
|
||||
_clear_cookie(response, COOKIE_NAME_REFRESH_TOKEN)
|
||||
|
||||
|
||||
def clear_csrf_token_from_cookie(response: Response):
|
||||
_clear_cookie(response, COOKIE_NAME_CSRF_TOKEN, http_only=False)
|
||||
|
||||
|
||||
def check_csrf_token(request: Request, user_id: str):
|
||||
# some apis are sent by beacon, so we need to bypass csrf token check
|
||||
# since these APIs are post, they are already protected by SameSite: Lax, so csrf is not required.
|
||||
def _unauthorized():
|
||||
raise Unauthorized("CSRF token is missing or invalid.")
|
||||
|
||||
for pattern in CSRF_WHITE_LIST:
|
||||
if pattern.match(request.path):
|
||||
return
|
||||
|
||||
csrf_token = extract_csrf_token(request)
|
||||
csrf_token_from_cookie = extract_csrf_token_from_cookie(request)
|
||||
|
||||
if csrf_token != csrf_token_from_cookie:
|
||||
_unauthorized()
|
||||
|
||||
if not csrf_token:
|
||||
_unauthorized()
|
||||
verified = {}
|
||||
try:
|
||||
verified = PassportService().verify(csrf_token)
|
||||
except:
|
||||
_unauthorized()
|
||||
|
||||
if verified.get("sub") != user_id:
|
||||
_unauthorized()
|
||||
|
||||
exp: int | None = verified.get("exp")
|
||||
if not exp:
|
||||
_unauthorized()
|
||||
else:
|
||||
time_now = int(datetime.now().timestamp())
|
||||
if exp < time_now:
|
||||
_unauthorized()
|
||||
|
||||
|
||||
def generate_csrf_token(user_id: str) -> str:
|
||||
exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
payload = {
|
||||
"exp": int(exp_dt.timestamp()),
|
||||
"sub": user_id,
|
||||
}
|
||||
return PassportService().issue(payload)
|
||||
|
|
@ -22,6 +22,7 @@ from libs.helper import RateLimiter, TokenManager
|
|||
from libs.passport import PassportService
|
||||
from libs.password import compare_password, hash_password, valid_password
|
||||
from libs.rsa import generate_key_pair
|
||||
from libs.token import generate_csrf_token
|
||||
from models.account import (
|
||||
Account,
|
||||
AccountIntegrate,
|
||||
|
|
@ -76,6 +77,7 @@ logger = logging.getLogger(__name__)
|
|||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
csrf_token: str
|
||||
|
||||
|
||||
REFRESH_TOKEN_PREFIX = "refresh_token:"
|
||||
|
|
@ -403,10 +405,11 @@ class AccountService:
|
|||
|
||||
access_token = AccountService.get_account_jwt_token(account=account)
|
||||
refresh_token = _generate_refresh_token()
|
||||
csrf_token = generate_csrf_token(account.id)
|
||||
|
||||
AccountService._store_refresh_token(refresh_token, account.id)
|
||||
|
||||
return TokenPair(access_token=access_token, refresh_token=refresh_token)
|
||||
return TokenPair(access_token=access_token, refresh_token=refresh_token, csrf_token=csrf_token)
|
||||
|
||||
@staticmethod
|
||||
def logout(*, account: Account):
|
||||
|
|
@ -431,8 +434,9 @@ class AccountService:
|
|||
|
||||
AccountService._delete_refresh_token(refresh_token, account.id)
|
||||
AccountService._store_refresh_token(new_refresh_token, account.id)
|
||||
csrf_token = generate_csrf_token(account.id)
|
||||
|
||||
return TokenPair(access_token=new_access_token, refresh_token=new_refresh_token)
|
||||
return TokenPair(access_token=new_access_token, refresh_token=new_refresh_token, csrf_token=csrf_token)
|
||||
|
||||
@staticmethod
|
||||
def load_logged_in_account(*, account_id: str):
|
||||
|
|
|
|||
|
|
@ -46,17 +46,17 @@ class EnterpriseService:
|
|||
|
||||
class WebAppAuth:
|
||||
@classmethod
|
||||
def is_user_allowed_to_access_webapp(cls, user_id: str, app_code: str):
|
||||
params = {"userId": user_id, "appCode": app_code}
|
||||
def is_user_allowed_to_access_webapp(cls, user_id: str, app_id: str):
|
||||
params = {"userId": user_id, "appId": app_id}
|
||||
data = EnterpriseRequest.send_request("GET", "/webapp/permission", params=params)
|
||||
|
||||
return data.get("result", False)
|
||||
|
||||
@classmethod
|
||||
def batch_is_user_allowed_to_access_webapps(cls, user_id: str, app_codes: list[str]):
|
||||
if not app_codes:
|
||||
def batch_is_user_allowed_to_access_webapps(cls, user_id: str, app_ids: list[str]):
|
||||
if not app_ids:
|
||||
return {}
|
||||
body = {"userId": user_id, "appCodes": app_codes}
|
||||
body = {"userId": user_id, "appIds": app_ids}
|
||||
data = EnterpriseRequest.send_request("POST", "/webapp/permission/batch", json=body)
|
||||
if not data:
|
||||
raise ValueError("No data found.")
|
||||
|
|
|
|||
|
|
@ -172,7 +172,8 @@ class WebAppAuthService:
|
|||
return WebAppAuthType.EXTERNAL
|
||||
|
||||
if app_code:
|
||||
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code)
|
||||
app_id = AppService.get_app_id_by_code(app_code)
|
||||
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id)
|
||||
return cls.get_app_auth_type(access_mode=webapp_settings.access_mode)
|
||||
|
||||
raise ValueError("Could not determine app authentication type.")
|
||||
|
|
|
|||
|
|
@ -863,13 +863,14 @@ class TestWebAppAuthService:
|
|||
- Mock service integration
|
||||
"""
|
||||
# Arrange: Setup mock for enterprise service
|
||||
mock_webapp_auth = type("MockWebAppAuth", (), {"access_mode": "sso_verified"})()
|
||||
mock_external_service_dependencies["app_service"].get_app_id_by_code.return_value = "mock_app_id"
|
||||
setting = type("MockWebAppAuth", (), {"access_mode": "sso_verified"})()
|
||||
mock_external_service_dependencies[
|
||||
"enterprise_service"
|
||||
].WebAppAuth.get_app_access_mode_by_code.return_value = mock_webapp_auth
|
||||
].WebAppAuth.get_app_access_mode_by_id.return_value = setting
|
||||
|
||||
# Act: Execute authentication type determination
|
||||
result = WebAppAuthService.get_app_auth_type(app_code="mock_app_code")
|
||||
result: WebAppAuthType = WebAppAuthService.get_app_auth_type(app_code="mock_app_code")
|
||||
|
||||
# Assert: Verify correct result
|
||||
assert result == WebAppAuthType.EXTERNAL
|
||||
|
|
@ -877,7 +878,7 @@ class TestWebAppAuthService:
|
|||
# Verify mock service was called correctly
|
||||
mock_external_service_dependencies[
|
||||
"enterprise_service"
|
||||
].WebAppAuth.get_app_access_mode_by_code.assert_called_once_with("mock_app_code")
|
||||
].WebAppAuth.get_app_access_mode_by_id.assert_called_once_with(app_id="mock_app_id")
|
||||
|
||||
def test_get_app_auth_type_no_parameters(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -179,9 +179,7 @@ class TestOAuthCallback:
|
|||
|
||||
oauth_setup["provider"].get_access_token.assert_called_once_with("test_code")
|
||||
oauth_setup["provider"].get_user_info.assert_called_once_with("access_token")
|
||||
mock_redirect.assert_called_once_with(
|
||||
"http://localhost:3000?access_token=jwt_access_token&refresh_token=jwt_refresh_token"
|
||||
)
|
||||
mock_redirect.assert_called_once_with("http://localhost:3000")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
|
|
@ -224,8 +222,8 @@ class TestOAuthCallback:
|
|||
# CLOSED status: Currently NOT handled, will proceed to login (security issue)
|
||||
# This documents actual behavior. See test_defensive_check_for_closed_account_status for details
|
||||
(
|
||||
AccountStatus.CLOSED,
|
||||
"http://localhost:3000?access_token=jwt_access_token&refresh_token=jwt_refresh_token",
|
||||
AccountStatus.CLOSED.value,
|
||||
"http://localhost:3000",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
@ -268,6 +266,7 @@ class TestOAuthCallback:
|
|||
mock_token_pair = MagicMock()
|
||||
mock_token_pair.access_token = "jwt_access_token"
|
||||
mock_token_pair.refresh_token = "jwt_refresh_token"
|
||||
mock_token_pair.csrf_token = "csrf_token"
|
||||
mock_account_service.login.return_value = mock_token_pair
|
||||
|
||||
with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
|
||||
|
|
@ -299,6 +298,12 @@ class TestOAuthCallback:
|
|||
mock_account.status = AccountStatus.PENDING
|
||||
mock_generate_account.return_value = mock_account
|
||||
|
||||
mock_token_pair = MagicMock()
|
||||
mock_token_pair.access_token = "jwt_access_token"
|
||||
mock_token_pair.refresh_token = "jwt_refresh_token"
|
||||
mock_token_pair.csrf_token = "csrf_token"
|
||||
mock_account_service.login.return_value = mock_token_pair
|
||||
|
||||
with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
|
||||
resource.get("github")
|
||||
|
||||
|
|
@ -361,6 +366,7 @@ class TestOAuthCallback:
|
|||
mock_token_pair = MagicMock()
|
||||
mock_token_pair.access_token = "jwt_access_token"
|
||||
mock_token_pair.refresh_token = "jwt_refresh_token"
|
||||
mock_token_pair.csrf_token = "csrf_token"
|
||||
mock_account_service.login.return_value = mock_token_pair
|
||||
|
||||
# Execute OAuth callback
|
||||
|
|
@ -368,9 +374,7 @@ class TestOAuthCallback:
|
|||
resource.get("github")
|
||||
|
||||
# Verify current behavior: login succeeds (this is NOT ideal)
|
||||
mock_redirect.assert_called_once_with(
|
||||
"http://localhost:3000?access_token=jwt_access_token&refresh_token=jwt_refresh_token"
|
||||
)
|
||||
mock_redirect.assert_called_once_with("http://localhost:3000")
|
||||
mock_account_service.login.assert_called_once()
|
||||
|
||||
# Document expected behavior in comments:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ from flask import Blueprint, Flask
|
|||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import BadRequest, Unauthorized
|
||||
|
||||
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN
|
||||
from core.errors.error import AppInvokeQuotaExceededError
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.external_api import ExternalApi
|
||||
|
||||
|
||||
|
|
@ -120,3 +122,66 @@ def test_external_api_param_mapping_and_quota_and_exc_info_none():
|
|||
assert res.status_code in (400, 429)
|
||||
finally:
|
||||
ext.sys.exc_info = orig_exc_info # type: ignore[assignment]
|
||||
|
||||
|
||||
def test_unauthorized_and_force_logout_clears_cookies():
|
||||
"""Test that UnauthorizedAndForceLogout error clears auth cookies"""
|
||||
|
||||
class UnauthorizedAndForceLogout(BaseHTTPException):
|
||||
error_code = "unauthorized_and_force_logout"
|
||||
description = "Unauthorized and force logout."
|
||||
code = 401
|
||||
|
||||
app = Flask(__name__)
|
||||
bp = Blueprint("test", __name__)
|
||||
api = ExternalApi(bp)
|
||||
|
||||
@api.route("/force-logout")
|
||||
class ForceLogout(Resource): # type: ignore
|
||||
def get(self): # type: ignore
|
||||
raise UnauthorizedAndForceLogout()
|
||||
|
||||
app.register_blueprint(bp, url_prefix="/api")
|
||||
client = app.test_client()
|
||||
|
||||
# Set cookies first
|
||||
client.set_cookie(COOKIE_NAME_ACCESS_TOKEN, "test_access_token")
|
||||
client.set_cookie(COOKIE_NAME_CSRF_TOKEN, "test_csrf_token")
|
||||
client.set_cookie(COOKIE_NAME_REFRESH_TOKEN, "test_refresh_token")
|
||||
|
||||
# Make request that should trigger cookie clearing
|
||||
res = client.get("/api/force-logout")
|
||||
|
||||
# Verify response
|
||||
assert res.status_code == 401
|
||||
data = res.get_json()
|
||||
assert data["code"] == "unauthorized_and_force_logout"
|
||||
assert data["status"] == 401
|
||||
assert "WWW-Authenticate" in res.headers
|
||||
|
||||
# Verify Set-Cookie headers are present to clear cookies
|
||||
set_cookie_headers = res.headers.getlist("Set-Cookie")
|
||||
assert len(set_cookie_headers) == 3, f"Expected 3 Set-Cookie headers, got {len(set_cookie_headers)}"
|
||||
|
||||
# Verify each cookie is being cleared (empty value and expired)
|
||||
cookie_names_found = set()
|
||||
for cookie_header in set_cookie_headers:
|
||||
# Check for cookie names
|
||||
if COOKIE_NAME_ACCESS_TOKEN in cookie_header:
|
||||
cookie_names_found.add(COOKIE_NAME_ACCESS_TOKEN)
|
||||
assert '""' in cookie_header or "=" in cookie_header # Empty value
|
||||
assert "Expires=Thu, 01 Jan 1970" in cookie_header # Expired
|
||||
elif COOKIE_NAME_CSRF_TOKEN in cookie_header:
|
||||
cookie_names_found.add(COOKIE_NAME_CSRF_TOKEN)
|
||||
assert '""' in cookie_header or "=" in cookie_header
|
||||
assert "Expires=Thu, 01 Jan 1970" in cookie_header
|
||||
elif COOKIE_NAME_REFRESH_TOKEN in cookie_header:
|
||||
cookie_names_found.add(COOKIE_NAME_REFRESH_TOKEN)
|
||||
assert '""' in cookie_header or "=" in cookie_header
|
||||
assert "Expires=Thu, 01 Jan 1970" in cookie_header
|
||||
|
||||
# Verify all three cookies are present
|
||||
assert len(cookie_names_found) == 3
|
||||
assert COOKIE_NAME_ACCESS_TOKEN in cookie_names_found
|
||||
assert COOKIE_NAME_CSRF_TOKEN in cookie_names_found
|
||||
assert COOKIE_NAME_REFRESH_TOKEN in cookie_names_found
|
||||
|
|
|
|||
|
|
@ -19,10 +19,15 @@ class MockUser(UserMixin):
|
|||
return self._is_authenticated
|
||||
|
||||
|
||||
def mock_csrf_check(*args, **kwargs):
|
||||
return
|
||||
|
||||
|
||||
class TestLoginRequired:
|
||||
"""Test cases for login_required decorator."""
|
||||
|
||||
@pytest.fixture
|
||||
@patch("libs.login.check_csrf_token", mock_csrf_check)
|
||||
def setup_app(self, app: Flask):
|
||||
"""Set up Flask app with login manager."""
|
||||
# Initialize login manager
|
||||
|
|
@ -39,6 +44,7 @@ class TestLoginRequired:
|
|||
|
||||
return app
|
||||
|
||||
@patch("libs.login.check_csrf_token", mock_csrf_check)
|
||||
def test_authenticated_user_can_access_protected_view(self, setup_app: Flask):
|
||||
"""Test that authenticated users can access protected views."""
|
||||
|
||||
|
|
@ -53,6 +59,7 @@ class TestLoginRequired:
|
|||
result = protected_view()
|
||||
assert result == "Protected content"
|
||||
|
||||
@patch("libs.login.check_csrf_token", mock_csrf_check)
|
||||
def test_unauthenticated_user_cannot_access_protected_view(self, setup_app: Flask):
|
||||
"""Test that unauthenticated users are redirected."""
|
||||
|
||||
|
|
@ -68,6 +75,7 @@ class TestLoginRequired:
|
|||
assert result == "Unauthorized"
|
||||
setup_app.login_manager.unauthorized.assert_called_once()
|
||||
|
||||
@patch("libs.login.check_csrf_token", mock_csrf_check)
|
||||
def test_login_disabled_allows_unauthenticated_access(self, setup_app: Flask):
|
||||
"""Test that LOGIN_DISABLED config bypasses authentication."""
|
||||
|
||||
|
|
@ -87,6 +95,7 @@ class TestLoginRequired:
|
|||
# Ensure unauthorized was not called
|
||||
setup_app.login_manager.unauthorized.assert_not_called()
|
||||
|
||||
@patch("libs.login.check_csrf_token", mock_csrf_check)
|
||||
def test_options_request_bypasses_authentication(self, setup_app: Flask):
|
||||
"""Test that OPTIONS requests are exempt from authentication."""
|
||||
|
||||
|
|
@ -103,6 +112,7 @@ class TestLoginRequired:
|
|||
# Ensure unauthorized was not called
|
||||
setup_app.login_manager.unauthorized.assert_not_called()
|
||||
|
||||
@patch("libs.login.check_csrf_token", mock_csrf_check)
|
||||
def test_flask_2_compatibility(self, setup_app: Flask):
|
||||
"""Test Flask 2.x compatibility with ensure_sync."""
|
||||
|
||||
|
|
@ -120,6 +130,7 @@ class TestLoginRequired:
|
|||
assert result == "Synced content"
|
||||
setup_app.ensure_sync.assert_called_once()
|
||||
|
||||
@patch("libs.login.check_csrf_token", mock_csrf_check)
|
||||
def test_flask_1_compatibility(self, setup_app: Flask):
|
||||
"""Test Flask 1.x compatibility without ensure_sync."""
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
from constants import COOKIE_NAME_ACCESS_TOKEN
|
||||
from libs.token import extract_access_token
|
||||
|
||||
|
||||
class MockRequest:
|
||||
def __init__(self, headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]):
|
||||
self.headers: dict[str, str] = headers
|
||||
self.cookies: dict[str, str] = cookies
|
||||
self.args: dict[str, str] = args
|
||||
|
||||
|
||||
def test_extract_access_token():
|
||||
def _mock_request(headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]):
|
||||
return MockRequest(headers, cookies, args)
|
||||
|
||||
test_cases = [
|
||||
(_mock_request({"Authorization": "Bearer 123"}, {}, {}), "123"),
|
||||
(_mock_request({}, {COOKIE_NAME_ACCESS_TOKEN: "123"}, {}), "123"),
|
||||
(_mock_request({}, {}, {}), None),
|
||||
(_mock_request({"Authorization": "Bearer_aaa 123"}, {}, {}), None),
|
||||
]
|
||||
for request, expected in test_cases:
|
||||
assert extract_access_token(request) == expected # pyright: ignore[reportArgumentType]
|
||||
|
|
@ -2,16 +2,17 @@
|
|||
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { removeAccessToken } from '@/app/components/share/utils'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share'
|
||||
import { webAppLogout } from '@/service/webapp-auth'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const { t } = useTranslation()
|
||||
const shareCode = useWebAppStore(s => s.shareCode)
|
||||
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
|
||||
const updateAppParams = useWebAppStore(s => s.updateAppParams)
|
||||
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
|
||||
|
|
@ -41,11 +42,11 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
|||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams, pathname])
|
||||
|
||||
const backToHome = useCallback(() => {
|
||||
removeAccessToken()
|
||||
const backToHome = useCallback(async () => {
|
||||
await webAppLogout(shareCode!)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
}, [getSigninUrl, router, webAppLogout, shareCode])
|
||||
|
||||
if (appInfoError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
'use client'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import { checkOrSetAccessToken, removeAccessToken, setAccessToken } from '@/app/components/share/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { webAppLoginStatus, webAppLogout } from '@/service/webapp-auth'
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
|
||||
|
||||
const Splash: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -18,9 +19,9 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
|||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const redirectUrl = searchParams.get('redirect_url')
|
||||
const tokenFromUrl = searchParams.get('web_sso_token')
|
||||
const message = searchParams.get('message')
|
||||
const code = searchParams.get('code')
|
||||
const tokenFromUrl = searchParams.get('web_sso_token')
|
||||
const getSigninUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('message')
|
||||
|
|
@ -28,35 +29,66 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
|||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams])
|
||||
|
||||
const backToHome = useCallback(() => {
|
||||
removeAccessToken()
|
||||
const backToHome = useCallback(async () => {
|
||||
await webAppLogout(shareCode!)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
}, [getSigninUrl, router, webAppLogout, shareCode])
|
||||
|
||||
const needCheckIsLogin = webAppAccessMode !== AccessMode.PUBLIC
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if(tokenFromUrl)
|
||||
setWebAppAccessToken(tokenFromUrl)
|
||||
|
||||
const redirectOrFinish = () => {
|
||||
if (redirectUrl)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
else
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const proceedToAuth = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (message)
|
||||
return
|
||||
if (shareCode && tokenFromUrl && redirectUrl) {
|
||||
localStorage.setItem('webapp_access_token', tokenFromUrl)
|
||||
const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: tokenFromUrl })
|
||||
await setAccessToken(shareCode, tokenResp.access_token)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
return
|
||||
const { userLoggedIn, appLoggedIn } = await webAppLoginStatus(needCheckIsLogin, shareCode!)
|
||||
|
||||
if (userLoggedIn && appLoggedIn) {
|
||||
redirectOrFinish()
|
||||
}
|
||||
if (shareCode && redirectUrl && localStorage.getItem('webapp_access_token')) {
|
||||
const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: localStorage.getItem('webapp_access_token') })
|
||||
await setAccessToken(shareCode, tokenResp.access_token)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
return
|
||||
else if (!userLoggedIn && !appLoggedIn) {
|
||||
proceedToAuth()
|
||||
}
|
||||
if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) {
|
||||
await checkOrSetAccessToken(shareCode)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
else if (!userLoggedIn && appLoggedIn) {
|
||||
redirectOrFinish()
|
||||
}
|
||||
else if (userLoggedIn && !appLoggedIn) {
|
||||
try {
|
||||
const { access_token } = await fetchAccessToken({ appCode: shareCode! })
|
||||
setWebAppPassport(shareCode!, access_token)
|
||||
redirectOrFinish()
|
||||
}
|
||||
catch (error) {
|
||||
await webAppLogout(shareCode!)
|
||||
proceedToAuth()
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [shareCode, redirectUrl, router, tokenFromUrl, message, webAppAccessMode])
|
||||
}, [
|
||||
shareCode,
|
||||
redirectUrl,
|
||||
router,
|
||||
message,
|
||||
webAppAccessMode,
|
||||
needCheckIsLogin,
|
||||
tokenFromUrl])
|
||||
|
||||
if (message) {
|
||||
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
|
||||
|
|
@ -64,12 +96,8 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
|||
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')}</span>
|
||||
</div>
|
||||
}
|
||||
if (tokenFromUrl) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) {
|
||||
|
||||
if (isLoading) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import Input from '@/app/components/base/input'
|
|||
import Toast from '@/app/components/base/toast'
|
||||
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
|
||||
import I18NContext from '@/context/i18n'
|
||||
import { setAccessToken } from '@/app/components/share/utils'
|
||||
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
|
||||
export default function CheckCode() {
|
||||
|
|
@ -62,9 +62,9 @@ export default function CheckCode() {
|
|||
setIsLoading(true)
|
||||
const ret = await webAppEmailLoginWithCode({ email, code, token })
|
||||
if (ret.result === 'success') {
|
||||
localStorage.setItem('webapp_access_token', ret.data.access_token)
|
||||
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: ret.data.access_token })
|
||||
await setAccessToken(appCode, tokenResp.access_token)
|
||||
setWebAppAccessToken(ret.data.access_token)
|
||||
const { access_token } = await fetchAccessToken({ appCode: appCode! })
|
||||
setWebAppPassport(appCode!, access_token)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,15 +11,13 @@ import { webAppLogin } from '@/service/common'
|
|||
import Input from '@/app/components/base/input'
|
||||
import I18NContext from '@/context/i18n'
|
||||
import { noop } from 'lodash-es'
|
||||
import { setAccessToken } from '@/app/components/share/utils'
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
|
||||
|
||||
type MailAndPasswordAuthProps = {
|
||||
isEmailSetup: boolean
|
||||
}
|
||||
|
||||
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
|
||||
|
||||
export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18NContext)
|
||||
|
|
@ -43,8 +41,8 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
|
|||
|
||||
return appCode
|
||||
}, [redirectUrl])
|
||||
const appCode = getAppCodeFromRedirectUrl()
|
||||
const handleEmailPasswordLogin = async () => {
|
||||
const appCode = getAppCodeFromRedirectUrl()
|
||||
if (!email) {
|
||||
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
|
||||
return
|
||||
|
|
@ -60,13 +58,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
|
|||
Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') })
|
||||
return
|
||||
}
|
||||
if (!passwordRegex.test(password)) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.error.passwordInvalid'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!redirectUrl || !appCode) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
|
|
@ -88,9 +80,10 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
|
|||
body: loginData,
|
||||
})
|
||||
if (res.result === 'success') {
|
||||
localStorage.setItem('webapp_access_token', res.data.access_token)
|
||||
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: res.data.access_token })
|
||||
await setAccessToken(appCode, tokenResp.access_token)
|
||||
setWebAppAccessToken(res.data.access_token)
|
||||
|
||||
const { access_token } = await fetchAccessToken({ appCode: appCode! })
|
||||
setWebAppPassport(appCode!, access_token)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
}
|
||||
else {
|
||||
|
|
@ -141,9 +134,9 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
|
|||
</label>
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
id="password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
handleEmailPasswordLogin()
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
|||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { removeAccessToken } from '@/app/components/share/utils'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import NormalForm from './normalForm'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import ExternalMemberSsoAuth from './components/external-member-sso-auth'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { webAppLogout } from '@/service/webapp-auth'
|
||||
|
||||
const WebSSOForm: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -26,11 +26,12 @@ const WebSSOForm: FC = () => {
|
|||
return `/webapp-signin?${params.toString()}`
|
||||
}, [redirectUrl])
|
||||
|
||||
const backToHome = useCallback(() => {
|
||||
removeAccessToken()
|
||||
const shareCode = useWebAppStore(s => s.shareCode)
|
||||
const backToHome = useCallback(async () => {
|
||||
await webAppLogout(shareCode!)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
}, [getSigninUrl, router, webAppLogout, shareCode])
|
||||
|
||||
if (!redirectUrl) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import Button from '@/app/components/base/button'
|
|||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
checkEmailExisted,
|
||||
logout,
|
||||
resetEmail,
|
||||
sendVerifyCode,
|
||||
verifyEmail,
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
import { noop } from 'lodash-es'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import type { ResponseError } from '@/service/fetch'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
|
|
@ -167,15 +167,12 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
|||
setStep(STEP.verifyNew)
|
||||
}
|
||||
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
const handleLogout = async () => {
|
||||
await logout({
|
||||
url: '/logout',
|
||||
params: {},
|
||||
})
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
localStorage.removeItem('console_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
router.push('/signin')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import {
|
|||
} from '@remixicon/react'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { logout } from '@/service/common'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
|
||||
export type IAppSelector = {
|
||||
isMobile: boolean
|
||||
|
|
@ -23,15 +23,12 @@ export default function AppSelector() {
|
|||
const { userProfile } = useAppContext()
|
||||
const { isEducationAccount } = useProviderContext()
|
||||
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
const handleLogout = async () => {
|
||||
await logout({
|
||||
url: '/logout',
|
||||
params: {},
|
||||
})
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
localStorage.removeItem('console_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
router.push('/signin')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import Button from '@/app/components/base/button'
|
|||
import CustomDialog from '@/app/components/base/dialog'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { logout } from '@/service/common'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
|
||||
type DeleteAccountProps = {
|
||||
onCancel: () => void
|
||||
|
|
@ -22,14 +22,11 @@ export default function FeedBack(props: DeleteAccountProps) {
|
|||
const [userFeedback, setUserFeedback] = useState('')
|
||||
const { isPending, mutateAsync: sendFeedback } = useDeleteAccountFeedback()
|
||||
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
const handleSuccess = useCallback(async () => {
|
||||
try {
|
||||
await logout({
|
||||
url: '/logout',
|
||||
params: {},
|
||||
})
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('console_token')
|
||||
await logout()
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
router.push('/signin')
|
||||
Toast.notify({ type: 'info', message: t('common.account.deleteSuccessTip') })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,22 @@ import cn from '@/utils/classnames'
|
|||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { AppContextProvider } from '@/context/app-context'
|
||||
import { useMemo } from 'react'
|
||||
import { useIsLogin } from '@/service/use-common'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
useDocumentTitle('')
|
||||
const isLoggedIn = useMemo(() => {
|
||||
try {
|
||||
return Boolean(localStorage.getItem('console_token') && localStorage.getItem('refresh_token'))
|
||||
}
|
||||
catch { return false }
|
||||
}, [])
|
||||
const { isLoading, data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
|
||||
if(isLoading) {
|
||||
return (
|
||||
<div className='flex min-h-screen w-full justify-center bg-background-default-burn'>
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
RiTranslate2,
|
||||
} from '@remixicon/react'
|
||||
import dayjs from 'dayjs'
|
||||
import { useIsLogin } from '@/service/use-common'
|
||||
|
||||
export const OAUTH_AUTHORIZE_PENDING_KEY = 'oauth_authorize_pending'
|
||||
export const REDIRECT_URL_KEY = 'oauth_redirect_url'
|
||||
|
|
@ -74,17 +75,13 @@ export default function OAuthAuthorize() {
|
|||
const client_id = decodeURIComponent(searchParams.get('client_id') || '')
|
||||
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
|
||||
const { userProfile } = useAppContext()
|
||||
const { data: authAppInfo, isLoading, isError } = useOAuthAppInfo(client_id, redirect_uri)
|
||||
const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri)
|
||||
const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp()
|
||||
const hasNotifiedRef = useRef(false)
|
||||
|
||||
const isLoggedIn = useMemo(() => {
|
||||
try {
|
||||
return Boolean(localStorage.getItem('console_token') && localStorage.getItem('refresh_token'))
|
||||
}
|
||||
catch { return false }
|
||||
}, [])
|
||||
|
||||
const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
const isLoading = isOAuthLoading || isIsLoginLoading
|
||||
const onLoginSwitchClick = () => {
|
||||
try {
|
||||
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const AccessControlDialog = ({
|
|||
}, [onClose])
|
||||
return (
|
||||
<Transition appear show={show} as={Fragment}>
|
||||
<Dialog as="div" open={true} className="relative z-20" onClose={() => null}>
|
||||
<Dialog as="div" open={true} className="relative z-[99]" onClose={() => null}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
|
@ -32,7 +32,7 @@ const AccessControlDialog = ({
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="bg-background-overlay/25 fixed inset-0" />
|
||||
<div className="fixed inset-0 bg-background-overlay" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 flex items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export default function AddMemberOrGroupDialog() {
|
|||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
{open && <FloatingOverlay />}
|
||||
<PortalToFollowElemContent className='z-[25]'>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<div className='relative flex max-h-[400px] w-[400px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
|
||||
<div className='sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]'>
|
||||
<Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('app.accessControlDialog.operateGroupAndMember.searchPlaceholder') as string} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useAsyncEffect } from 'ahooks'
|
||||
import { useThemeContext } from '../embedded-chatbot/theme/theme-context'
|
||||
import {
|
||||
ChatWithHistoryContext,
|
||||
|
|
@ -18,8 +17,6 @@ import ChatWrapper from './chat-wrapper'
|
|||
import type { InstalledApp } from '@/models/explore'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { checkOrSetAccessToken } from '@/app/components/share/utils'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import cn from '@/utils/classnames'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
|
|
@ -201,36 +198,6 @@ const ChatWithHistoryWrapWithCheckToken: FC<ChatWithHistoryWrapProps> = ({
|
|||
installedAppInfo,
|
||||
className,
|
||||
}) => {
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
|
||||
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false)
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!initialized) {
|
||||
if (!installedAppInfo) {
|
||||
try {
|
||||
await checkOrSetAccessToken()
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
else {
|
||||
setIsUnknownReason(true)
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!initialized)
|
||||
return null
|
||||
|
||||
if (appUnavailable)
|
||||
return <AppUnavailable isUnknownReason={isUnknownReason} />
|
||||
|
||||
return (
|
||||
<ChatWithHistoryWrap
|
||||
installedAppInfo={installedAppInfo}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import Compliance from './compliance'
|
|||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||
import { logout } from '@/service/common'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
|
|
@ -33,6 +32,7 @@ import { IS_CLOUD_EDITION } from '@/config'
|
|||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
|
||||
export default function AppSelector() {
|
||||
const itemClassName = `
|
||||
|
|
@ -49,15 +49,12 @@ export default function AppSelector() {
|
|||
const { isEducationAccount } = useProviderContext()
|
||||
const { setShowAccountSettingModal } = useModalContext()
|
||||
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
const handleLogout = async () => {
|
||||
await logout({
|
||||
url: '/logout',
|
||||
params: {},
|
||||
})
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
localStorage.removeItem('console_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
// To avoid use other account's education notice info
|
||||
localStorage.removeItem('education-reverify-prev-expire-at')
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const useNodesSyncDraft = () => {
|
|||
|
||||
if (postParams) {
|
||||
navigator.sendBeacon(
|
||||
`${API_PREFIX}${postParams.url}?_token=${localStorage.getItem('console_token')}`,
|
||||
`${API_PREFIX}${postParams.url}`,
|
||||
JSON.stringify(postParams.params),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type { SiteInfo } from '@/models/share'
|
|||
import cn from '@/utils/classnames'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { webAppLogout } from '@/service/webapp-auth'
|
||||
|
||||
type Props = {
|
||||
data?: SiteInfo
|
||||
|
|
@ -49,11 +50,11 @@ const MenuDropdown: FC<Props> = ({
|
|||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('webapp_access_token')
|
||||
const shareCode = useWebAppStore(s => s.shareCode)
|
||||
const handleLogout = useCallback(async () => {
|
||||
await webAppLogout(shareCode!)
|
||||
router.replace(`/webapp-signin?redirect_url=${pathname}`)
|
||||
}, [router, pathname])
|
||||
}, [router, pathname, webAppLogout, shareCode])
|
||||
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
import { CONVERSATION_ID_INFO } from '../base/chat/constants'
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
import { getProcessedSystemVariablesFromUrlParams } from '../base/chat/utils'
|
||||
|
||||
export const isTokenV1 = (token: Record<string, any>) => {
|
||||
return !token.version
|
||||
}
|
||||
|
|
@ -9,55 +5,3 @@ export const isTokenV1 = (token: Record<string, any>) => {
|
|||
export const getInitialTokenV2 = (): Record<string, any> => ({
|
||||
version: 2,
|
||||
})
|
||||
|
||||
export const checkOrSetAccessToken = async (appCode?: string | null) => {
|
||||
const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0]
|
||||
const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id
|
||||
const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2())
|
||||
let accessTokenJson = getInitialTokenV2()
|
||||
try {
|
||||
accessTokenJson = JSON.parse(accessToken)
|
||||
if (isTokenV1(accessTokenJson))
|
||||
accessTokenJson = getInitialTokenV2()
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
|
||||
if (!accessTokenJson[sharedToken]?.[userId || 'DEFAULT']) {
|
||||
const webAppAccessToken = localStorage.getItem('webapp_access_token')
|
||||
const res = await fetchAccessToken({ appCode: sharedToken, userId, webAppAccessToken })
|
||||
accessTokenJson[sharedToken] = {
|
||||
...accessTokenJson[sharedToken],
|
||||
[userId || 'DEFAULT']: res.access_token,
|
||||
}
|
||||
localStorage.setItem('token', JSON.stringify(accessTokenJson))
|
||||
localStorage.removeItem(CONVERSATION_ID_INFO)
|
||||
}
|
||||
}
|
||||
|
||||
export const setAccessToken = (sharedToken: string, token: string, user_id?: string) => {
|
||||
const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2())
|
||||
let accessTokenJson = getInitialTokenV2()
|
||||
try {
|
||||
accessTokenJson = JSON.parse(accessToken)
|
||||
if (isTokenV1(accessTokenJson))
|
||||
accessTokenJson = getInitialTokenV2()
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
|
||||
localStorage.removeItem(CONVERSATION_ID_INFO)
|
||||
|
||||
accessTokenJson[sharedToken] = {
|
||||
...accessTokenJson[sharedToken],
|
||||
[user_id || 'DEFAULT']: token,
|
||||
}
|
||||
localStorage.setItem('token', JSON.stringify(accessTokenJson))
|
||||
}
|
||||
|
||||
export const removeAccessToken = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('webapp_access_token')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,7 @@ const SwrInitializer = ({
|
|||
}: SwrInitializerProps) => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const consoleToken = decodeURIComponent(searchParams.get('access_token') || '')
|
||||
const refreshToken = decodeURIComponent(searchParams.get('refresh_token') || '')
|
||||
const consoleTokenFromLocalStorage = localStorage?.getItem('console_token')
|
||||
const refreshTokenFromLocalStorage = localStorage?.getItem('refresh_token')
|
||||
// Tokens are now stored in cookies, no need to check localStorage
|
||||
const pathname = usePathname()
|
||||
const [init, setInit] = useState(false)
|
||||
|
||||
|
|
@ -57,21 +54,12 @@ const SwrInitializer = ({
|
|||
router.replace('/install')
|
||||
return
|
||||
}
|
||||
if (!((consoleToken && refreshToken) || (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage))) {
|
||||
router.replace('/signin')
|
||||
return
|
||||
}
|
||||
if (searchParams.has('access_token') || searchParams.has('refresh_token')) {
|
||||
if (consoleToken)
|
||||
localStorage.setItem('console_token', consoleToken)
|
||||
if (refreshToken)
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
const redirectUrl = resolvePostLoginRedirect(searchParams)
|
||||
if (redirectUrl)
|
||||
location.replace(redirectUrl)
|
||||
else
|
||||
router.replace(pathname)
|
||||
}
|
||||
|
||||
const redirectUrl = resolvePostLoginRedirect(searchParams)
|
||||
if (redirectUrl)
|
||||
location.replace(redirectUrl)
|
||||
else
|
||||
router.replace(pathname)
|
||||
|
||||
setInit(true)
|
||||
}
|
||||
|
|
@ -79,7 +67,7 @@ const SwrInitializer = ({
|
|||
router.replace('/signin')
|
||||
}
|
||||
})()
|
||||
}, [isSetupFinished, router, pathname, searchParams, consoleToken, refreshToken, consoleTokenFromLocalStorage, refreshTokenFromLocalStorage])
|
||||
}, [isSetupFinished, router, pathname, searchParams])
|
||||
|
||||
return init
|
||||
? (
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export const useNodesSyncDraft = () => {
|
|||
|
||||
if (postParams) {
|
||||
navigator.sendBeacon(
|
||||
`${API_PREFIX}/apps/${params.appId}/workflows/draft?_token=${localStorage.getItem('console_token')}`,
|
||||
`${API_PREFIX}/apps/${params.appId}/workflows/draft`,
|
||||
JSON.stringify(postParams.params),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,24 +2,21 @@ import { useTranslation } from 'react-i18next'
|
|||
import { useRouter } from 'next/navigation'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { logout } from '@/service/common'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { Triangle } from '@/app/components/base/icons/src/public/education'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
|
||||
const UserInfo = () => {
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
const handleLogout = async () => {
|
||||
await logout({
|
||||
url: '/logout',
|
||||
params: {},
|
||||
})
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
localStorage.removeItem('console_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
router.push('/signin')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,8 +72,6 @@ const InstallForm = () => {
|
|||
|
||||
// Store tokens and redirect to apps if login successful
|
||||
if (loginRes.result === 'success') {
|
||||
localStorage.setItem('console_token', loginRes.data.access_token)
|
||||
localStorage.setItem('refresh_token', loginRes.data.refresh_token)
|
||||
router.replace('/apps')
|
||||
}
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -42,8 +42,6 @@ export default function CheckCode() {
|
|||
setIsLoading(true)
|
||||
const ret = await emailLoginWithCode({ email, code, token })
|
||||
if (ret.result === 'success') {
|
||||
localStorage.setItem('console_token', ret.data.access_token)
|
||||
localStorage.setItem('refresh_token', ret.data.refresh_token)
|
||||
if (invite_token) {
|
||||
router.replace(`/signin/invite-settings?${searchParams.toString()}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
|
|||
const [password, setPassword] = useState('')
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleEmailPasswordLogin = async () => {
|
||||
if (!email) {
|
||||
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
|
||||
|
|
@ -66,8 +67,6 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
|
|||
router.replace(`/signin/invite-settings?${searchParams.toString()}`)
|
||||
}
|
||||
else {
|
||||
localStorage.setItem('console_token', res.data.access_token)
|
||||
localStorage.setItem('refresh_token', res.data.refresh_token)
|
||||
const redirectUrl = resolvePostLoginRedirect(searchParams)
|
||||
router.replace(redirectUrl || '/apps')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,7 @@ export default function InviteSettingsPage() {
|
|||
},
|
||||
})
|
||||
if (res.result === 'success') {
|
||||
localStorage.setItem('console_token', res.data.access_token)
|
||||
localStorage.setItem('refresh_token', res.data.refresh_token)
|
||||
// Tokens are now stored in cookies by the backend
|
||||
await setLocaleOnClient(language, false)
|
||||
const redirectUrl = resolvePostLoginRedirect(searchParams)
|
||||
router.replace(redirectUrl || '/apps')
|
||||
|
|
|
|||
|
|
@ -16,16 +16,18 @@ import { IS_CE_EDITION } from '@/config'
|
|||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { resolvePostLoginRedirect } from './utils/post-login-redirect'
|
||||
import Split from './split'
|
||||
import { useIsLogin } from '@/service/use-common'
|
||||
|
||||
const NormalForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const consoleToken = decodeURIComponent(searchParams.get('access_token') || '')
|
||||
const refreshToken = decodeURIComponent(searchParams.get('refresh_token') || '')
|
||||
const { isLoading: isCheckLoading, data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
const message = decodeURIComponent(searchParams.get('message') || '')
|
||||
const invite_token = decodeURIComponent(searchParams.get('invite_token') || '')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isInitCheckLoading, setInitCheckLoading] = useState(true)
|
||||
const isLoading = isCheckLoading || loginData?.logged_in || isInitCheckLoading
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
|
||||
const [showORLine, setShowORLine] = useState(false)
|
||||
|
|
@ -36,9 +38,7 @@ const NormalForm = () => {
|
|||
|
||||
const init = useCallback(async () => {
|
||||
try {
|
||||
if (consoleToken && refreshToken) {
|
||||
localStorage.setItem('console_token', consoleToken)
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
if (isLoggedIn) {
|
||||
const redirectUrl = resolvePostLoginRedirect(searchParams)
|
||||
router.replace(redirectUrl || '/apps')
|
||||
return
|
||||
|
|
@ -67,12 +67,12 @@ const NormalForm = () => {
|
|||
console.error(error)
|
||||
setAllMethodsAreDisabled(true)
|
||||
}
|
||||
finally { setIsLoading(false) }
|
||||
}, [consoleToken, refreshToken, message, router, invite_token, isInviteLink, systemFeatures])
|
||||
finally { setInitCheckLoading(false) }
|
||||
}, [isLoggedIn, message, router, invite_token, isInviteLink, systemFeatures])
|
||||
useEffect(() => {
|
||||
init()
|
||||
}, [init])
|
||||
if (isLoading || consoleToken) {
|
||||
if (isLoading) {
|
||||
return <div className={
|
||||
cn(
|
||||
'flex w-full grow flex-col items-center justify-center',
|
||||
|
|
|
|||
|
|
@ -52,14 +52,12 @@ const ChangePasswordForm = () => {
|
|||
new_password: password,
|
||||
password_confirm: confirmPassword,
|
||||
})
|
||||
const { result, data } = res as MailRegisterResponse
|
||||
const { result } = res as MailRegisterResponse
|
||||
if (result === 'success') {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
localStorage.setItem('console_token', data.access_token)
|
||||
localStorage.setItem('refresh_token', data.refresh_token)
|
||||
router.replace('/apps')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,17 @@ export const getMaxToken = (modelId: string) => {
|
|||
|
||||
export const LOCALE_COOKIE_NAME = 'locale'
|
||||
|
||||
export const CSRF_COOKIE_NAME = () => {
|
||||
const isSecure = API_PREFIX.startsWith('https://')
|
||||
return isSecure ? '__Host-csrf_token' : 'csrf_token'
|
||||
}
|
||||
export const CSRF_HEADER_NAME = 'X-CSRF-Token'
|
||||
export const ACCESS_TOKEN_LOCAL_STORAGE_NAME = 'access_token'
|
||||
export const PASSPORT_LOCAL_STORAGE_NAME = (appCode: string) => `passport-${appCode}`
|
||||
export const PASSPORT_HEADER_NAME = 'X-App-Passport'
|
||||
|
||||
export const WEB_APP_SHARE_CODE_HEADER_NAME = 'X-App-Code'
|
||||
|
||||
export const DEFAULT_VALUE_MAX_LEN = 48
|
||||
export const DEFAULT_PARAGRAPH_VALUE_MAX_LEN = 1000
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,12 @@
|
|||
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { checkOrSetAccessToken } from '@/app/components/share/utils'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import type { AppData, AppMeta } from '@/models/share'
|
||||
import { useGetWebAppAccessModeByCode } from '@/service/use-share'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import { useGlobalPublicStore } from './global-public-context'
|
||||
|
||||
|
|
@ -71,24 +69,13 @@ const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
|
|||
}, [shareCode, updateShareCode])
|
||||
|
||||
const { isFetching, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode)
|
||||
const [isFetchingAccessToken, setIsFetchingAccessToken] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (accessModeResult?.accessMode) {
|
||||
if (accessModeResult?.accessMode)
|
||||
updateWebAppAccessMode(accessModeResult.accessMode)
|
||||
if (accessModeResult.accessMode === AccessMode.PUBLIC) {
|
||||
setIsFetchingAccessToken(true)
|
||||
checkOrSetAccessToken(shareCode).finally(() => {
|
||||
setIsFetchingAccessToken(false)
|
||||
})
|
||||
}
|
||||
else {
|
||||
setIsFetchingAccessToken(false)
|
||||
}
|
||||
}
|
||||
}, [accessModeResult, updateWebAppAccessMode, shareCode])
|
||||
|
||||
if (isGlobalPending || isFetching || isFetchingAccessToken) {
|
||||
if (isGlobalPending || isFetching) {
|
||||
return <div className='flex h-full w-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,63 +2,6 @@ import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikCo
|
|||
import type { App, AppMode, AppTemplate, SiteConfig } from '@/types/app'
|
||||
import type { Dependency } from '@/app/components/plugins/types'
|
||||
|
||||
/* export type App = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
mode: AppMode
|
||||
enable_site: boolean
|
||||
enable_api: boolean
|
||||
api_rpm: number
|
||||
api_rph: number
|
||||
is_demo: boolean
|
||||
model_config: AppModelConfig
|
||||
providers: Array<{ provider: string; token_is_set: boolean }>
|
||||
site: SiteConfig
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type AppModelConfig = {
|
||||
provider: string
|
||||
model_id: string
|
||||
configs: {
|
||||
prompt_template: string
|
||||
prompt_variables: Array<PromptVariable>
|
||||
completion_params: CompletionParam
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptVariable = {
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
type: string | number
|
||||
default: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
export type CompletionParam = {
|
||||
max_tokens: number
|
||||
temperature: number
|
||||
top_p: number
|
||||
echo: boolean
|
||||
stop: string[]
|
||||
presence_penalty: number
|
||||
frequency_penalty: number
|
||||
}
|
||||
|
||||
export type SiteConfig = {
|
||||
access_token: string
|
||||
title: string
|
||||
author: string
|
||||
support_email: string
|
||||
default_language: string
|
||||
customize_domain: string
|
||||
theme: string
|
||||
customize_token_strategy: 'must' | 'allow' | 'not_allow'
|
||||
prompt_public: boolean
|
||||
} */
|
||||
|
||||
export enum DSLImportMode {
|
||||
YAML_CONTENT = 'yaml-content',
|
||||
YAML_URL = 'yaml-url',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from '@/config'
|
||||
import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_CE_EDITION, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config'
|
||||
import { refreshAccessTokenOrRelogin } from './refresh-token'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
|
@ -21,15 +21,16 @@ import type {
|
|||
WorkflowFinishedResponse,
|
||||
WorkflowStartedResponse,
|
||||
} from '@/types/workflow'
|
||||
import { removeAccessToken } from '@/app/components/share/utils'
|
||||
import type { FetchOptionType, ResponseError } from './fetch'
|
||||
import { ContentType, base, getAccessToken, getBaseOptions } from './fetch'
|
||||
import { ContentType, base, getBaseOptions } from './fetch'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import type {
|
||||
DataSourceNodeCompletedResponse,
|
||||
DataSourceNodeErrorResponse,
|
||||
DataSourceNodeProcessingResponse,
|
||||
} from '@/types/pipeline'
|
||||
import Cookies from 'js-cookie'
|
||||
import { getWebAppPassport } from './webapp-auth'
|
||||
const TIME_OUT = 100000
|
||||
|
||||
export type IOnDataMoreInfo = {
|
||||
|
|
@ -122,14 +123,19 @@ function unicodeToChar(text: string) {
|
|||
})
|
||||
}
|
||||
|
||||
const WBB_APP_LOGIN_PATH = '/webapp-signin'
|
||||
function requiredWebSSOLogin(message?: string, code?: number) {
|
||||
const params = new URLSearchParams()
|
||||
// prevent redirect loop
|
||||
if(globalThis.location.pathname === WBB_APP_LOGIN_PATH)
|
||||
return
|
||||
|
||||
params.append('redirect_url', encodeURIComponent(`${globalThis.location.pathname}${globalThis.location.search}`))
|
||||
if (message)
|
||||
params.append('message', message)
|
||||
if (code)
|
||||
params.append('code', String(code))
|
||||
globalThis.location.href = `${globalThis.location.origin}${basePath}/webapp-signin?${params.toString()}`
|
||||
globalThis.location.href = `${globalThis.location.origin}${basePath}/${WBB_APP_LOGIN_PATH}?${params.toString()}`
|
||||
}
|
||||
|
||||
export function format(text: string) {
|
||||
|
|
@ -338,12 +344,14 @@ type UploadResponse = {
|
|||
|
||||
export const upload = async (options: UploadOptions, isPublicAPI?: boolean, url?: string, searchParams?: string): Promise<UploadResponse> => {
|
||||
const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
|
||||
const token = await getAccessToken(isPublicAPI)
|
||||
const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]
|
||||
const defaultOptions = {
|
||||
method: 'POST',
|
||||
url: (url ? `${urlPrefix}${url}` : `${urlPrefix}/files/upload`) + (searchParams || ''),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
|
||||
[PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode),
|
||||
[WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode,
|
||||
},
|
||||
}
|
||||
const mergedOptions = {
|
||||
|
|
@ -413,14 +421,17 @@ export const ssePost = async (
|
|||
} = otherOptions
|
||||
const abortController = new AbortController()
|
||||
|
||||
const token = localStorage.getItem('console_token')
|
||||
// No need to get token from localStorage, cookies will be sent automatically
|
||||
|
||||
const baseOptions = getBaseOptions()
|
||||
const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]
|
||||
const options = Object.assign({}, baseOptions, {
|
||||
method: 'POST',
|
||||
signal: abortController.signal,
|
||||
headers: new Headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
|
||||
[WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode,
|
||||
[PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode),
|
||||
}),
|
||||
} as RequestInit, fetchOptions)
|
||||
|
||||
|
|
@ -439,9 +450,6 @@ export const ssePost = async (
|
|||
if (body)
|
||||
options.body = JSON.stringify(body)
|
||||
|
||||
const accessToken = await getAccessToken(isPublicAPI)
|
||||
; (options.headers as Headers).set('Authorization', `Bearer ${accessToken}`)
|
||||
|
||||
globalThis.fetch(urlWithPrefix, options as RequestInit)
|
||||
.then((res) => {
|
||||
if (!/^[23]\d{2}$/.test(String(res.status))) {
|
||||
|
|
@ -452,15 +460,11 @@ export const ssePost = async (
|
|||
if (data.code === 'web_app_access_denied')
|
||||
requiredWebSSOLogin(data.message, 403)
|
||||
|
||||
if (data.code === 'web_sso_auth_required') {
|
||||
removeAccessToken()
|
||||
if (data.code === 'web_sso_auth_required')
|
||||
requiredWebSSOLogin()
|
||||
}
|
||||
|
||||
if (data.code === 'unauthorized') {
|
||||
removeAccessToken()
|
||||
if (data.code === 'unauthorized')
|
||||
requiredWebSSOLogin()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -551,13 +555,11 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
|
|||
return Promise.reject(err)
|
||||
}
|
||||
if (code === 'web_sso_auth_required') {
|
||||
removeAccessToken()
|
||||
requiredWebSSOLogin()
|
||||
return Promise.reject(err)
|
||||
}
|
||||
if (code === 'unauthorized_and_force_logout') {
|
||||
localStorage.removeItem('console_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Cookies will be cleared by the backend
|
||||
globalThis.location.reload()
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
|
@ -566,7 +568,6 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
|
|||
silent,
|
||||
} = otherOptionsForBaseFetch
|
||||
if (isPublicAPI && code === 'unauthorized') {
|
||||
removeAccessToken()
|
||||
requiredWebSSOLogin()
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import type { SystemFeatures } from '@/types/feature'
|
|||
|
||||
type LoginSuccess = {
|
||||
result: 'success'
|
||||
data: { access_token: string; refresh_token: string }
|
||||
data: { access_token: string }
|
||||
}
|
||||
type LoginFail = {
|
||||
result: 'fail'
|
||||
|
|
@ -56,10 +56,6 @@ export const webAppLogin: Fetcher<LoginResponse, { url: string; body: Record<str
|
|||
return post(url, { body }, { isPublicAPI: true }) as Promise<LoginResponse>
|
||||
}
|
||||
|
||||
export const fetchNewToken: Fetcher<CommonResponse & { data: { access_token: string; refresh_token: string } }, { body: Record<string, any> }> = ({ body }) => {
|
||||
return post('/refresh-token', { body }) as Promise<CommonResponse & { data: { access_token: string; refresh_token: string } }>
|
||||
}
|
||||
|
||||
export const setup: Fetcher<CommonResponse, { body: Record<string, any> }> = ({ body }) => {
|
||||
return post<CommonResponse>('/setup', { body })
|
||||
}
|
||||
|
|
@ -84,10 +80,6 @@ export const updateUserProfile: Fetcher<CommonResponse, { url: string; body: Rec
|
|||
return post<CommonResponse>(url, { body })
|
||||
}
|
||||
|
||||
export const logout: Fetcher<CommonResponse, { url: string; params: Record<string, any> }> = ({ url, params }) => {
|
||||
return get<CommonResponse>(url, params)
|
||||
}
|
||||
|
||||
export const fetchLangGeniusVersion: Fetcher<LangGeniusVersionResponse, { url: string; params: Record<string, any> }> = ({ url, params }) => {
|
||||
return get<LangGeniusVersionResponse>(url, { params })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import type { AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Hooks } fro
|
|||
import ky from 'ky'
|
||||
import type { IOtherOptions } from './base'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { API_PREFIX, APP_VERSION, MARKETPLACE_API_PREFIX, PUBLIC_API_PREFIX } from '@/config'
|
||||
import { getInitialTokenV2, isTokenV1 } from '@/app/components/share/utils'
|
||||
import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/chat/utils'
|
||||
import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config'
|
||||
import Cookies from 'js-cookie'
|
||||
import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth'
|
||||
|
||||
const TIME_OUT = 100000
|
||||
|
||||
|
|
@ -69,35 +69,15 @@ const beforeErrorToast = (otherOptions: IOtherOptions): BeforeErrorHook => {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getAccessToken(isPublicAPI?: boolean) {
|
||||
if (isPublicAPI) {
|
||||
const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
|
||||
const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id
|
||||
const accessToken = localStorage.getItem('token') || JSON.stringify({ version: 2 })
|
||||
let accessTokenJson: Record<string, any> = { version: 2 }
|
||||
try {
|
||||
accessTokenJson = JSON.parse(accessToken)
|
||||
if (isTokenV1(accessTokenJson))
|
||||
accessTokenJson = getInitialTokenV2()
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
return accessTokenJson[sharedToken]?.[userId || 'DEFAULT']
|
||||
}
|
||||
else {
|
||||
return localStorage.getItem('console_token') || ''
|
||||
}
|
||||
}
|
||||
|
||||
const beforeRequestPublicAuthorization: BeforeRequestHook = async (request) => {
|
||||
const token = await getAccessToken(true)
|
||||
request.headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
|
||||
const beforeRequestAuthorization: BeforeRequestHook = async (request) => {
|
||||
const accessToken = await getAccessToken()
|
||||
request.headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
const beforeRequestPublicWithCode = (request: Request) => {
|
||||
request.headers.set('Authorization', `Bearer ${getWebAppAccessToken()}`)
|
||||
const shareCode = globalThis.location.pathname.split('/').filter(Boolean).pop() || ''
|
||||
// some pages does not end with share code, so we need to check it
|
||||
// TODO: maybe find a better way to access app code?
|
||||
if (shareCode === 'webapp-signin' || shareCode === 'check-code')
|
||||
return
|
||||
request.headers.set(WEB_APP_SHARE_CODE_HEADER_NAME, shareCode)
|
||||
request.headers.set(PASSPORT_HEADER_NAME, getWebAppPassport(shareCode))
|
||||
}
|
||||
|
||||
const baseHooks: Hooks = {
|
||||
|
|
@ -148,6 +128,8 @@ async function base<T>(url: string, options: FetchOptionType = {}, otherOptions:
|
|||
}
|
||||
|
||||
const fetchPathname = base + (url.startsWith('/') ? url : `/${url}`)
|
||||
if (!isMarketplaceAPI)
|
||||
(headers as any).set(CSRF_HEADER_NAME, Cookies.get(CSRF_COOKIE_NAME()) || '')
|
||||
|
||||
if (deleteContentType)
|
||||
(headers as any).delete('Content-Type')
|
||||
|
|
@ -165,8 +147,7 @@ async function base<T>(url: string, options: FetchOptionType = {}, otherOptions:
|
|||
],
|
||||
beforeRequest: [
|
||||
...baseHooks.beforeRequest || [],
|
||||
isPublicAPI && beforeRequestPublicAuthorization,
|
||||
!isPublicAPI && !isMarketplaceAPI && beforeRequestAuthorization,
|
||||
isPublicAPI && beforeRequestPublicWithCode,
|
||||
].filter((h): h is BeforeRequestHook => Boolean(h)),
|
||||
afterResponse: [
|
||||
...baseHooks.afterResponse || [],
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ async function getNewAccessToken(timeout: number): Promise<void> {
|
|||
globalThis.localStorage.setItem(LOCAL_STORAGE_KEY, '1')
|
||||
globalThis.localStorage.setItem('last_refresh_time', new Date().getTime().toString())
|
||||
globalThis.addEventListener('beforeunload', releaseRefreshLock)
|
||||
const refresh_token = globalThis.localStorage.getItem('refresh_token')
|
||||
|
||||
// Do not use baseFetch to refresh tokens.
|
||||
// If a 401 response occurs and baseFetch itself attempts to refresh the token,
|
||||
|
|
@ -48,10 +47,11 @@ async function getNewAccessToken(timeout: number): Promise<void> {
|
|||
// that does not call baseFetch and uses a single retry mechanism.
|
||||
const [error, ret] = await fetchWithRetry(globalThis.fetch(`${API_PREFIX}/refresh-token`, {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Important: include cookies in the request
|
||||
headers: {
|
||||
'Content-Type': 'application/json;utf-8',
|
||||
},
|
||||
body: JSON.stringify({ refresh_token }),
|
||||
// No body needed - refresh token is in cookie
|
||||
}))
|
||||
if (error) {
|
||||
return Promise.reject(error)
|
||||
|
|
@ -59,10 +59,6 @@ async function getNewAccessToken(timeout: number): Promise<void> {
|
|||
else {
|
||||
if (ret.status === 401)
|
||||
return Promise.reject(ret)
|
||||
|
||||
const { data } = await ret.json()
|
||||
globalThis.localStorage.setItem('console_token', data.access_token)
|
||||
globalThis.localStorage.setItem('refresh_token', data.refresh_token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import type {
|
|||
} from '@/models/share'
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import { WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config'
|
||||
import { getWebAppAccessToken } from './webapp-auth'
|
||||
|
||||
function getAction(action: 'get' | 'post' | 'del' | 'patch', isInstalledApp: boolean) {
|
||||
switch (action) {
|
||||
|
|
@ -286,16 +288,14 @@ export const textToAudioStream = (url: string, isPublicAPI: boolean, header: { c
|
|||
return (getAction('post', !isPublicAPI))(url, { body, header }, { needAllResponseContent: true })
|
||||
}
|
||||
|
||||
export const fetchAccessToken = async ({ appCode, userId, webAppAccessToken }: { appCode: string, userId?: string, webAppAccessToken?: string | null }) => {
|
||||
export const fetchAccessToken = async ({ userId, appCode }: { userId?: string, appCode: string }) => {
|
||||
const headers = new Headers()
|
||||
headers.append('X-App-Code', appCode)
|
||||
headers.append(WEB_APP_SHARE_CODE_HEADER_NAME, appCode)
|
||||
headers.append('Authorization', `Bearer ${getWebAppAccessToken()}`)
|
||||
const params = new URLSearchParams()
|
||||
if (webAppAccessToken)
|
||||
params.append('web_app_access_token', webAppAccessToken)
|
||||
if (userId)
|
||||
params.append('user_id', userId)
|
||||
userId && params.append('user_id', userId)
|
||||
const url = `/passport?${params.toString()}`
|
||||
return get(url, { headers }) as Promise<{ access_token: string }>
|
||||
return get<{ access_token: string }>(url, { headers }) as Promise<{ access_token: string }>
|
||||
}
|
||||
|
||||
export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const useMailValidity = () => {
|
|||
})
|
||||
}
|
||||
|
||||
export type MailRegisterResponse = { result: string, data: { access_token: string, refresh_token: string } }
|
||||
export type MailRegisterResponse = { result: string, data: {} }
|
||||
|
||||
export const useMailRegister = () => {
|
||||
return useMutation({
|
||||
|
|
@ -106,3 +106,23 @@ export const useSchemaTypeDefinitions = () => {
|
|||
queryFn: () => get<SchemaTypeDefinition[]>('/spec/schema-definitions'),
|
||||
})
|
||||
}
|
||||
|
||||
type isLogin = {
|
||||
logged_in: boolean
|
||||
}
|
||||
|
||||
export const useIsLogin = () => {
|
||||
return useQuery<isLogin>({
|
||||
queryKey: [NAME_SPACE, 'is-login'],
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
queryFn: () => get<isLogin>('/login/status'),
|
||||
})
|
||||
}
|
||||
|
||||
export const useLogout = () => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'logout'],
|
||||
mutationFn: () => post('/logout'),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export const useGetWebAppAccessModeByCode = (code: string | null) => {
|
|||
queryKey: [NAME_SPACE, 'appAccessMode', code],
|
||||
queryFn: () => getAppAccessModeByAppCode(code!),
|
||||
enabled: !!code,
|
||||
staleTime: 0, // backend change the access mode may cause the logic error. Because /permission API is no cached.
|
||||
gcTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { ACCESS_TOKEN_LOCAL_STORAGE_NAME, PASSPORT_LOCAL_STORAGE_NAME } from '@/config'
|
||||
import { getPublic, postPublic } from './base'
|
||||
|
||||
export function setWebAppAccessToken(token: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME, token)
|
||||
}
|
||||
|
||||
export function setWebAppPassport(shareCode: string, token: string) {
|
||||
localStorage.setItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode), token)
|
||||
}
|
||||
|
||||
export function getWebAppAccessToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME) || ''
|
||||
}
|
||||
|
||||
export function getWebAppPassport(shareCode: string) {
|
||||
return localStorage.getItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) || ''
|
||||
}
|
||||
|
||||
export function clearWebAppAccessToken() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME)
|
||||
}
|
||||
|
||||
export function clearWebAppPassport(shareCode: string) {
|
||||
localStorage.removeItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode))
|
||||
}
|
||||
|
||||
type isWebAppLogin = {
|
||||
logged_in: boolean
|
||||
app_logged_in: boolean
|
||||
}
|
||||
|
||||
export async function webAppLoginStatus(enabled: boolean, shareCode: string) {
|
||||
if (!enabled) {
|
||||
return {
|
||||
userLoggedIn: true,
|
||||
appLoggedIn: true,
|
||||
}
|
||||
}
|
||||
|
||||
// check remotely, the access token could be in cookie (enterprise SSO redirected with https)
|
||||
const { logged_in, app_logged_in } = await getPublic<isWebAppLogin>(`/login/status?app_code=${shareCode}`)
|
||||
return {
|
||||
userLoggedIn: logged_in,
|
||||
appLoggedIn: app_logged_in,
|
||||
}
|
||||
}
|
||||
|
||||
export async function webAppLogout(shareCode: string) {
|
||||
clearWebAppAccessToken()
|
||||
clearWebAppPassport(shareCode)
|
||||
await postPublic('/logout')
|
||||
}
|
||||
Loading…
Reference in New Issue