Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.git
.gitignore
.dev
docs
packaging
scripts
**/node_modules
**/dist
**/.venv
**/__pycache__
**/*.db
**/*.pyc
*.log
12 changes: 12 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@ STAFFDECK_ROLE="all"
WECHAT_ILINK_BASE_URL="https://ilinkai.weixin.qq.com"
CHANNEL_DELIVERY_POLL_SECONDS="1.0"
CHANNEL_DELIVERY_MAX_ATTEMPTS="8"
# OIDC 单点登录(默认关闭,配置 issuer 并 OIDC_ENABLED="true" 后生效)
OIDC_ENABLED="false"
OIDC_ISSUER=""
OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET=""
OIDC_SCOPES="openid profile email"
# OIDC_REDIRECT_URI 留空时按请求自动推导
OIDC_REDIRECT_URI=""
OIDC_TENANT_ID="tenant_demo"
OIDC_DEFAULT_ROLE="member"
OIDC_AUTO_PROVISION="true"
OIDC_CLOCK_SKEW_SECONDS="120"
91 changes: 91 additions & 0 deletions backend/app/api/oidc_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from __future__ import annotations

import logging
from urllib.parse import urlencode

from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlmodel import Session

from app.db import get_session
from app.security.auth import create_access_token
from app.security.oidc import (
OIDCLoginError,
OIDCNotConfigured,
OIDCStateError,
build_authorize_url,
complete_login,
oidc_display_name,
oidc_ready,
resolve_redirect_uri,
)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/auth/oidc", tags=["auth"])

# 回调成功后携带令牌落地到登录页;令牌放在 URL fragment(# 之后),不会进入
# 服务器日志/浏览器历史外的任何记录,由前端读取后写入会话存储并清理 hash。
_LOGIN_LANDING = "/login"


class OIDCConfig(BaseModel):
enabled: bool
name: str


@router.get("/config", response_model=OIDCConfig)
def oidc_config() -> OIDCConfig:
"""登录页探测 SSO 可用性(公开)。"""
return OIDCConfig(enabled=oidc_ready(), name=oidc_display_name())


@router.get("/authorize")
def oidc_authorize(request: Request, db: Session = Depends(get_session)) -> RedirectResponse:
"""发起 PKCE 授权流:生成并持久化 state,302 跳转 IdP。"""
try:
url = build_authorize_url(db, resolve_redirect_uri(request))
except OIDCNotConfigured:
return _error_redirect("OIDC 未启用或配置不完整")
except (OIDCLoginError, OIDCStateError) as exc:
logger.warning("OIDC authorize failed: %s", exc)
return _error_redirect("身份提供方配置异常,请联系管理员")
return RedirectResponse(url=url, status_code=302)


@router.get("/callback")
def oidc_callback(
request: Request,
code: str | None = Query(None),
state: str | None = Query(None),
db: Session = Depends(get_session),
) -> RedirectResponse:
"""IdP 回调:换 token → 校验 ID token → 映射用户 → 签发 StaffDeck JWT。"""
if not code or not state:
return _error_redirect("回调缺少 code 或 state 参数")
try:
user = complete_login(
db,
authorization_response=str(request.url),
state=state,
redirect_uri=resolve_redirect_uri(request),
)
except OIDCNotConfigured:
return _error_redirect("OIDC 未启用或配置不完整")
except OIDCStateError:
return _error_redirect("登录状态无效或已过期,请重新发起登录")
except OIDCLoginError as exc:
message = str(exc) if str(exc) else "登录失败,请联系管理员"
return _error_redirect(message)
except Exception: # 兜底:网络/解析等未预期异常不向用户泄露细节
logger.exception("OIDC callback unexpected failure")
return _error_redirect("登录失败,请联系管理员")

token = create_access_token(user)
return RedirectResponse(url=f"{_LOGIN_LANDING}#oidc_token={token}", status_code=302)


def _error_redirect(message: str) -> RedirectResponse:
params = urlencode({"oidc_error": message})
return RedirectResponse(url=f"{_LOGIN_LANDING}?{params}", status_code=302)
17 changes: 17 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ class Settings(BaseSettings):
# 否则常量失效或权限未开时,每条入站消息都会留下一条失败的 reaction 投递。
channel_dingtalk_reaction_enabled: bool = False

# OIDC (OpenID Connect) 单点登录。未配置 issuer 时视为关闭,登录页不显示 SSO 入口。
oidc_enabled: bool = False
oidc_issuer: str = ""
oidc_name: str = "" # 登录页 SSO 按钮展示名;留空时回退为 issuer 主机名
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_scopes: str = "openid profile email"
# 留空时按请求 base_url 自动推导(https://host/api/auth/oidc/callback),
# 生产建议显式配置,避免反向代理场景下 base_url 推导偏差。
oidc_redirect_uri: str = ""
# OIDC 用户归属租户与默认角色;首次登录自动建号(可关闭自动建号仅允许已有账号绑定)。
oidc_tenant_id: str = "tenant_demo"
oidc_default_role: str = "member"
oidc_auto_provision: bool = True
# ID token 校验时钟偏移容忍秒数
oidc_clock_skew_seconds: int = 120

model_config = SettingsConfigDict(
env_file=_os.environ.get("ULTRARAG_DOTENV", ".env"),
env_file_encoding="utf-8", extra="ignore",
Expand Down
9 changes: 9 additions & 0 deletions backend/app/db/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,16 @@ def _migrate_sqlite_skill_schema() -> None:
conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR NOT NULL DEFAULT 'member'"))
if "source" not in user_columns:
conn.execute(text("ALTER TABLE users ADD COLUMN source VARCHAR NOT NULL DEFAULT 'web'"))
if "oidc_sub" not in user_columns:
conn.execute(text("ALTER TABLE users ADD COLUMN oidc_sub VARCHAR"))
_migrate_user_source_backfill(conn)
# OIDC 用户按 sub 稳定映射;partial unique index 保证普通账号不受约束
conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_user_oidc_sub "
"ON users(tenant_id, oidc_sub) WHERE oidc_sub IS NOT NULL"
)
)

if "sessions" in tables:
session_columns = {column["name"] for column in inspector.get_columns("sessions")}
Expand Down
17 changes: 17 additions & 0 deletions backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,28 @@ class User(SQLModel, table=True):
role: str = Field(default="member", index=True)
# 账号来源:web=网页端创建;wechat 等=渠道懒建(用户管理列表默认隐藏)
source: str = Field(default="web", index=True)
# OIDC 唯一标识(sub claim):同一身份提供方下稳定不变,用于 SSO 用户映射;
# 空值表示非 OIDC 账号,unique index 为 partial(WHERE oidc_sub IS NOT NULL)
oidc_sub: Optional[str] = None
password_hash: str
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)


class OIDCAuthState(SQLModel, table=True):
"""OIDC 授权流服务端状态:authorize 生成的 state 与 PKCE verifier/nonce,
回调时一次性消费(校验后删除)。独立小表,create_all 自动建表,无需 ALTER。
"""

__tablename__ = "oidc_auth_states"

state: str = Field(primary_key=True)
code_verifier: str
nonce: str
created_at: datetime = Field(default_factory=utc_now)
expires_at: datetime


class UserAvatar(SQLModel, table=True):
"""用户头像:小图以 data_url 直接存库(与聊天附件内联方式一致),

Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
memories,
mock,
model_configs,
oidc_auth,
persona,
scheduled_tasks,
sessions,
Expand Down Expand Up @@ -75,6 +76,7 @@ def health() -> dict[str, str]:
app.include_router(agents.chat_router)
app.include_router(ui_config.chat_router)
app.include_router(auth.router)
app.include_router(oidc_auth.router)
app.include_router(agents.scope_router)
app.include_router(agents.enterprise_router)
app.include_router(general_skills.router)
Expand Down
Loading