mirror of
https://github.com/langgenius/dify.git
synced 2026-05-22 01:48:39 +08:00
Adds a CLI-friendly authorization flow so difyctl (and future
non-browser clients) can obtain user-scoped tokens without copy-
pasting cookies or raw API keys. Two grant paths share one device
flow surface:
1. Account branch — user signs in via the existing /signin
methods, /device page calls console-authed approve, mints a
dfoa_ token tied to (account_id, tenant).
2. External-SSO branch (EE) — /v1/oauth/device/sso-initiate signs
an SSOState envelope, hands off to Enterprise's external ACS,
receives a signed external-subject assertion, mints a dfoe_
token tied to (subject_email, subject_issuer).
API surface (all under /v1, EE-only endpoints 404 on CE):
POST /v1/oauth/device/code — RFC 8628 start
POST /v1/oauth/device/token — RFC 8628 poll
GET /v1/oauth/device/lookup — pre-validate user_code
GET /v1/oauth/device/sso-initiate — SSO branch entry
GET /v1/device/sso-complete — SSO callback sink
GET /v1/oauth/device/approval-context — /device cookie probe
POST /v1/oauth/device/approve-external — SSO approve
GET /v1/me — bearer subject lookup
DELETE /v1/oauth/authorizations/self — self-revoke
POST /console/api/oauth/device/approve — account approve
POST /console/api/oauth/device/deny — account deny
Core primitives:
- libs/oauth_bearer.py: prefix-keyed TokenKindRegistry +
BearerAuthenticator + validate_bearer decorator. Two-tier scope
(full vs apps:run) stamped from the registry, never from the DB.
- libs/jws.py: HS256 compact JWS keyed on the shared Dify
SECRET_KEY — same key-set verifies the SSOState envelope, the
external-subject assertion (minted by Enterprise), and the
approval-grant cookie.
- libs/device_flow_security.py: enterprise_only gate, approval-
grant cookie mint/verify/consume (Path=/v1/oauth/device,
HttpOnly, SameSite=Lax, Secure follows is_secure()), anti-
framing headers.
- libs/rate_limit.py: typed RateLimit / RateLimitScope dispatch
with composite-key buckets; both decorator + imperative form.
- services/oauth_device_flow.py: Redis state machine (PENDING ->
APPROVED|DENIED with atomic consume-on-poll), token mint via
partial unique index uq_oauth_active_per_device (rotates in
place), env-driven TTL policy.
Storage: oauth_access_tokens table with partial unique index on
(subject_email, subject_issuer, client_id, device_label) WHERE
revoked_at IS NULL. account_id NULL distinguishes external-SSO
rows. Hard-expire is CAS UPDATE (revoked_at + nullify token_hash)
so audit events keep their token_id. Retention pruner DELETEs
revoked + zombie-expired rows past OAUTH_ACCESS_TOKEN_RETENTION_DAYS.
Frontend: /device page with code-entry, chooser (account vs SSO),
authorize-account, authorize-sso views. SSO branch detaches from
the URL user_code and reads everything from the cookie via
/approval-context. Anti-framing headers on all responses.
Wiring: ENABLE_OAUTH_BEARER feature flag; ext_oauth_bearer binds
the authenticator at startup; clean_oauth_access_tokens_task
scheduled in ext_celery.
Spec: docs/specs/v1.0/server/{device-flow,tokens,middleware,security}.md
110 lines
3.7 KiB
Python
110 lines
3.7 KiB
Python
"""Typed rate-limit decorator over ``libs.helper.RateLimiter`` (sliding-
|
|
window Redis ZSET). Apply after auth decorators so scopes can read
|
|
``g.auth_ctx``. Use :func:`enforce` when the bucket key is computed
|
|
in-handler. RFC-8628 ``slow_down`` is inline — its response shape isn't
|
|
generic 429. Spec: docs/specs/v1.0/server/security.md.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import timedelta
|
|
from enum import StrEnum
|
|
from functools import wraps
|
|
from typing import Callable
|
|
|
|
from flask import g, request, session
|
|
from werkzeug.exceptions import TooManyRequests
|
|
|
|
from libs.helper import RateLimiter, extract_remote_ip
|
|
|
|
|
|
class RateLimitScope(StrEnum):
|
|
IP = "ip"
|
|
SESSION = "session"
|
|
ACCOUNT = "account"
|
|
SUBJECT_EMAIL = "subject_email"
|
|
TOKEN_ID = "token_id"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RateLimit:
|
|
limit: int
|
|
window: timedelta
|
|
scopes: tuple[RateLimitScope, ...]
|
|
|
|
|
|
LIMIT_DEVICE_CODE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,))
|
|
LIMIT_SSO_INITIATE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,))
|
|
LIMIT_APPROVE_EXT_PER_EMAIL = RateLimit(10, timedelta(hours=1), (RateLimitScope.SUBJECT_EMAIL,))
|
|
LIMIT_APPROVE_CONSOLE = RateLimit(10, timedelta(hours=1), (RateLimitScope.SESSION,))
|
|
LIMIT_LOOKUP_PUBLIC = RateLimit(60, timedelta(minutes=5), (RateLimitScope.IP,))
|
|
LIMIT_ME_PER_ACCOUNT = RateLimit(60, timedelta(minutes=1), (RateLimitScope.ACCOUNT,))
|
|
LIMIT_ME_PER_EMAIL = RateLimit(60, timedelta(minutes=1), (RateLimitScope.SUBJECT_EMAIL,))
|
|
|
|
|
|
def _one_key(scope: RateLimitScope) -> str:
|
|
match scope:
|
|
case RateLimitScope.IP:
|
|
return f"ip:{extract_remote_ip(request) or 'unknown'}"
|
|
case RateLimitScope.SESSION:
|
|
return f"session:{session.get('_id', 'anon')}"
|
|
case RateLimitScope.ACCOUNT:
|
|
ctx = getattr(g, "auth_ctx", None)
|
|
if ctx and ctx.account_id:
|
|
return f"account:{ctx.account_id}"
|
|
return "account:anon"
|
|
case RateLimitScope.SUBJECT_EMAIL:
|
|
ctx = getattr(g, "auth_ctx", None)
|
|
if ctx and ctx.subject_email:
|
|
return f"subject:{ctx.subject_email}"
|
|
return "subject:anon"
|
|
case RateLimitScope.TOKEN_ID:
|
|
ctx = getattr(g, "auth_ctx", None)
|
|
if ctx and ctx.token_id:
|
|
return f"token:{ctx.token_id}"
|
|
return "token:anon"
|
|
|
|
|
|
def _composite_key(scopes: tuple[RateLimitScope, ...]) -> str:
|
|
return "|".join(_one_key(s) for s in scopes)
|
|
|
|
|
|
def _limiter_prefix(scopes: tuple[RateLimitScope, ...]) -> str:
|
|
return "rl:" + "+".join(s.value for s in scopes)
|
|
|
|
|
|
def _build_limiter(spec: RateLimit) -> RateLimiter:
|
|
return RateLimiter(
|
|
prefix=_limiter_prefix(spec.scopes),
|
|
max_attempts=spec.limit,
|
|
time_window=int(spec.window.total_seconds()),
|
|
)
|
|
|
|
|
|
def rate_limit(spec: RateLimit) -> Callable:
|
|
"""Apply after auth decorators that the scopes read from."""
|
|
limiter = _build_limiter(spec)
|
|
|
|
def wrap(fn: Callable) -> Callable:
|
|
@wraps(fn)
|
|
def inner(*args, **kwargs):
|
|
key = _composite_key(spec.scopes)
|
|
if limiter.is_rate_limited(key):
|
|
raise TooManyRequests("rate_limited")
|
|
limiter.increment_rate_limit(key)
|
|
return fn(*args, **kwargs)
|
|
|
|
return inner
|
|
|
|
return wrap
|
|
|
|
|
|
def enforce(spec: RateLimit, *, key: str) -> None:
|
|
"""Imperative form — caller composes the bucket key to match scope
|
|
semantics (the key is opaque here).
|
|
"""
|
|
limiter = _build_limiter(spec)
|
|
if limiter.is_rate_limited(key):
|
|
raise TooManyRequests("rate_limited")
|
|
limiter.increment_rate_limit(key)
|