2025-10-19 21:29:04 +08:00
|
|
|
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,
|
2025-10-23 11:03:48 +08:00
|
|
|
COOKIE_NAME_WEBAPP_ACCESS_TOKEN,
|
2025-10-19 21:29:04 +08:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2025-10-28 10:04:24 +08:00
|
|
|
def _cookie_domain() -> str | None:
|
|
|
|
|
"""
|
|
|
|
|
Returns the normalized cookie domain.
|
|
|
|
|
|
|
|
|
|
Leading dots are stripped from the configured domain. Historically, a leading dot
|
|
|
|
|
indicated that a cookie should be sent to all subdomains, but modern browsers treat
|
|
|
|
|
'example.com' and '.example.com' identically. This normalization ensures consistent
|
|
|
|
|
behavior and avoids confusion.
|
|
|
|
|
"""
|
|
|
|
|
domain = dify_config.COOKIE_DOMAIN.strip()
|
|
|
|
|
domain = domain.removeprefix(".")
|
|
|
|
|
return domain or None
|
|
|
|
|
|
|
|
|
|
|
2025-10-19 21:29:04 +08:00
|
|
|
def _real_cookie_name(cookie_name: str) -> str:
|
2025-10-28 10:04:24 +08:00
|
|
|
if is_secure() and _cookie_domain() is None:
|
2025-10-19 21:29:04 +08:00
|
|
|
return "__Host-" + cookie_name
|
|
|
|
|
else:
|
|
|
|
|
return cookie_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _try_extract_from_header(request: Request) -> str | None:
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2025-10-24 13:09:34 +08:00
|
|
|
def extract_refresh_token(request: Request) -> str | None:
|
|
|
|
|
return request.cookies.get(_real_cookie_name(COOKIE_NAME_REFRESH_TOKEN))
|
|
|
|
|
|
|
|
|
|
|
2025-10-19 21:29:04 +08:00
|
|
|
def extract_csrf_token(request: Request) -> str | None:
|
|
|
|
|
return request.headers.get(HEADER_NAME_CSRF_TOKEN)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_csrf_token_from_cookie(request: Request) -> str | None:
|
|
|
|
|
return request.cookies.get(_real_cookie_name(COOKIE_NAME_CSRF_TOKEN))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_access_token(request: Request) -> str | None:
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2025-10-23 11:03:48 +08:00
|
|
|
def extract_webapp_access_token(request: Request) -> str | None:
|
|
|
|
|
return request.cookies.get(_real_cookie_name(COOKIE_NAME_WEBAPP_ACCESS_TOKEN)) or _try_extract_from_header(request)
|
|
|
|
|
|
|
|
|
|
|
2025-10-19 21:29:04 +08:00
|
|
|
def extract_webapp_passport(app_code: str, request: Request) -> str | None:
|
|
|
|
|
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,
|
2025-10-28 10:04:24 +08:00
|
|
|
domain=_cookie_domain(),
|
2025-10-19 21:29:04 +08:00
|
|
|
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,
|
2025-10-28 10:04:24 +08:00
|
|
|
domain=_cookie_domain(),
|
2025-10-19 21:29:04 +08:00
|
|
|
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,
|
2025-10-28 10:04:24 +08:00
|
|
|
domain=_cookie_domain(),
|
2025-10-19 21:29:04 +08:00
|
|
|
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="/",
|
2025-10-28 10:04:24 +08:00
|
|
|
domain=_cookie_domain(),
|
2025-10-19 21:29:04 +08:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2025-10-23 11:03:48 +08:00
|
|
|
def clear_webapp_access_token_from_cookie(response: Response, samesite: str = "Lax"):
|
|
|
|
|
_clear_cookie(response, COOKIE_NAME_WEBAPP_ACCESS_TOKEN, samesite)
|
|
|
|
|
|
|
|
|
|
|
2025-10-19 21:29:04 +08:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2025-10-28 10:04:24 +08:00
|
|
|
def build_force_logout_cookie_headers() -> list[str]:
|
|
|
|
|
"""
|
|
|
|
|
Generate Set-Cookie header values that clear all auth-related cookies.
|
|
|
|
|
This mirrors the behavior of the standard cookie clearing helpers while
|
|
|
|
|
allowing callers that do not have a Response instance to reuse the logic.
|
|
|
|
|
"""
|
|
|
|
|
response = Response()
|
|
|
|
|
clear_access_token_from_cookie(response)
|
|
|
|
|
clear_csrf_token_from_cookie(response)
|
|
|
|
|
clear_refresh_token_from_cookie(response)
|
|
|
|
|
return response.headers.getlist("Set-Cookie")
|
|
|
|
|
|
|
|
|
|
|
2025-10-19 21:29:04 +08:00
|
|
|
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)
|