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
16 changes: 16 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ server:
# 认证配置
auth:
session_duration_hours: 12 # 登录有效期(小时),超时后需重新登录
# 可选的 OIDC SSO,默认关闭;HTTPS 反向代理下使用
# oidc:
# enabled: true
# allow_http: false # 仅隔离测试环境使用 HTTP
# # Keycloak: https://keycloak.example.com/realms/security
# # Dex: https://dex.example.com/dex
# issuer: https://keycloak.example.com/realms/security
# # 可选:仅当 Provider 发现地址不符合 <issuer>/.well-known/openid-configuration 时填写
# # discovery_url: https://sso.example.com/custom/.well-known/openid-configuration
# client_id: cyberstrike
# login_label: Sign in with Keycloak # 也可自定义为中文
# # 推荐通过 CYBERSTRIKE_OIDC_CLIENT_SECRET 环境变量注入 client_secret
# redirect_uri: https://cyberstrike.example.com/api/auth/oidc/callback
# claims:
# username: preferred_username
# display_name: name
# 日志配置
log:
level: info # 日志级别: debug(调试), info(信息), warn(警告), error(错误)
Expand Down
124 changes: 124 additions & 0 deletions docs/zh-CN/oidc-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# OIDC 单点登录(实验性功能)

此功能默认关闭。它不需要数据库迁移,也不执行用户组同步。

## 配置

CyberStrikeAI 作为 OIDC Client,可以对接 Keycloak、Dex 等标准 OIDC Provider。当前一个实例只配置一个 Provider。

### Keycloak 示例

在 Keycloak 的 `security` Realm 注册 Client `cyberstrike`,启用 Standard Flow 和 Client Authentication,并登记完整的 Redirect URI:

```yaml
auth:
session_duration_hours: 12
oidc:
enabled: true
allow_http: false
issuer: https://keycloak.example.com/realms/security
client_id: cyberstrike
# 建议通过 CYBERSTRIKE_OIDC_CLIENT_SECRET 环境变量注入密钥
redirect_uri: https://cyberstrike.example.com/api/auth/oidc/callback
login_label: Sign in with Keycloak
claims:
username: preferred_username
display_name: name
```

默认发现地址:`https://keycloak.example.com/realms/security/.well-known/openid-configuration`。

### Dex 示例

Dex Provider 的相关配置:

```yaml
issuer: https://dex.example.com/dex
staticClients:
- id: cyberstrike
name: CyberStrikeAI
secret: YOUR_CLIENT_SECRET
redirectURIs:
- https://cyberstrike.example.com/api/auth/oidc/callback
```

CyberStrikeAI 对应配置:

```yaml
auth:
session_duration_hours: 12
oidc:
enabled: true
allow_http: false
issuer: https://dex.example.com/dex
client_id: cyberstrike
# 建议通过 CYBERSTRIKE_OIDC_CLIENT_SECRET 环境变量注入密钥
redirect_uri: https://cyberstrike.example.com/api/auth/oidc/callback
login_label: Sign in with Dex
claims:
username: preferred_username
display_name: name
```

默认发现地址:`https://dex.example.com/dex/.well-known/openid-configuration`。Dex 的具体 Claims 取决于上游连接器,首次登录前应确认 `preferred_username` 和 `name` 是否存在。

### 配置说明

- `issuer` 必须与 Discovery 响应和 ID Token 中的 Issuer 完全一致。
- `discovery_url` 是可选的;默认使用 `<issuer>/.well-known/openid-configuration`。如 Provider 使用非标准发现路径,才填写完整 URL;不会因此跳过 Issuer 校验。
- `login_label` 可指定登录按钮名称;缺省为 `Sign in with SSO`。
- `client_secret` 推荐通过 `CYBERSTRIKE_OIDC_CLIENT_SECRET` 环境变量提供。
- `redirect_uri` 必须与 OIDC Provider 注册的回调地址一致。
- `allow_http: true` 只应在明确接受明文传输风险的隔离测试环境使用;生产使用 HTTPS。
- 当前使用 `github.com/coreos/go-oidc/v3/oidc` 验证 ID Token,暂仅接受 RS256。Token 交换使用 `client_secret_post` 形式,须确认 Provider 支持。

首次 SSO 登录会按配置 Claim 创建本地用户,默认 viewer;现有 OIDC 用户按用户名及 issuer+sub 指纹校验登录。未启用 OIDC 的本地密码账号、内置 admin 均不会因同名而被自动关联。OIDC 新建用户的 password_hash 字段保存 !oidc: 开头的身份指纹,不能通过本地密码登录。不可随意修改 Username Claim 的值,也不能将 OIDC 账号密码重置为本地密码,否则会破坏绑定。因为无数据库身份映射表,单个用户只支持一个 OIDC 身份,且用户名在 IdP 中必须稳定、唯一、不允许回收给其他用户。

服务端通过已有 Session Token 和 RBAC 完成授权。浏览器回调收到安全 Cookie;前端从 /api/auth/validate 恢复原有的 Session。OIDC 退出并不自动执行 IdP 全局登出。**生产启用前务必在隔离环境开展登录、注销、重启、用户禁用、同名账号冲突、代理 HTTPS、WebSocket/SSE 和 MFA 回归验证。**


## 未来扩展:OIDC Groups 到 RBAC Roles(设计预留,尚未实现)

> **状态:仅设计,不属于当前 OIDC 登录功能。** 不要在当前配置中启用以下字段;当前 Go 代码不会解析或执行角色映射。本次扩展不修改数据库表结构、不同步 LDAP/SCIM 用户组。

### 拟议配置(不可直接用于当前版本)

```yaml
auth:
oidc:
# 当前已支持的 claims 配置保持不变
claims:
username: preferred_username
display_name: name
# groups: groups # 未来支持:OIDC ID Token / UserInfo 的组属性

# 未来可选配置;当前未实现
# role_mapping:
# enabled: false
# default_role: viewer
# mappings:
# cyberstrike-operators: operator
# cyberstrike-auditors: auditor
# # 高权限 admin 映射需要额外显式授权
```

### 建议语义与实现边界

1. `claims.groups` 只负责指定上游 Claims 字段,`role_mapping.mappings` 负责将组名映射到本地 RBAC Role ID;与 `username` / `display_name` 的字段映射职责分离。
2. 仅对已验证的 ID Token 或完成 `sub` 一致性验证的 UserInfo 提取 Groups。第一版仅支持字符串数组和组名精确匹配;不实现 LDAP 查询、正则、嵌套组及目录同步。
3. 一个用户可以匹配多个组;映射后的角色去重。没有匹配时默认使用 `viewer`;Claims 缺失、格式错误及未知 Role 应明确记录审计并使用拒绝或最小权限策略,不能默默保留旧的高权限角色。
4. **不直接把 OIDC 派生角色写入 `rbac_user_roles`**,以免与管理员人工分配的角色混淆。推荐由 OIDC 登录流程构建本次会话的有效角色集合,再由统一 RBAC 解析器计算 `Session.Roles`、`Session.Permissions`、`Session.PermissionScopes` 和 `Session.Scope`。本地密码登录行为保持原样。
5. `admin`、C2、WebShell 等高风险权限的自动授予应默认拒绝,必须有显式策略、授权审查与审计;禁止把单纯的 OIDC 登录成功解释为全权限操作员。
6. 现有 Session 是内存权限快照,上游组变更无法实时撤销既有会话。未来上线角色映射时需明确有效期、主动撤销策略、配置变更后的会话失效、用户禁用以及权限更新一致性;不应宣称实时同步。
7. 仍需处理 RBAC 资源访问路径(例如 `UserCanAccessResource`)、Agent/MCP 使用的 Principal,以及本地角色/会话角色权限合并的一致性。上线前补充多组映射、组移除、未知组、同名用户冲突、管理员映射、登录态过期等测试。

### 未来实施范围

预估额外约 250–450 行 Go 代码及测试(粗略估算),主要涉及 OIDC Claims 提取、组映射与 RBAC 有效权限解析。**当前版本不新增以上配置字段、不修改 RBAC、不修改数据库、不改变现有默认 Viewer 行为。**


## 会话与安全边界

OIDC 登录沿用 CyberStrikeAI 原有的 Session Token / RBAC 模型,当前前端会在验证成功后将 Session Token 保存至 localStorage。这是与现有密码登录兼容的最小改动,**不代表浏览器端的 Token 具有 HttpOnly 隔离**;如需 Cookie-only 模式,必须单独评估前端 API、WebSocket/SSE 与 CSRF 防护后再实施。注销时服务端撤销当前 Session,并清理 OIDC `auth_token` Cookie。OIDC Provider 侧的单点登出不在此功能范围内。

ID Token 由 `go-oidc` 验证,包含签名、Issuer、Audience、时间有效性及 Nonce;`discovery_url` 仅改变元数据获取地址,不放宽 Issuer 校验。外部 HTTP 连接仅供受控测试环境使用。
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
github.com/coreos/go-oidc/v3 v3.14.1
github.com/creack/pty v1.1.24
github.com/disintegration/imaging v1.6.2
github.com/eino-contrib/jsonschema v1.0.3
Expand All @@ -39,6 +40,7 @@ require (
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0
golang.org/x/text v0.37.0
golang.org/x/time v0.14.0
Expand Down Expand Up @@ -81,6 +83,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
Expand Down Expand Up @@ -142,7 +145,6 @@ require (
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
google.golang.org/api v0.189.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd9
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
Expand Down Expand Up @@ -145,6 +147,8 @@ github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand Down
6 changes: 6 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
notificationHandler := handler.NewNotificationHandler(db, agentHandler, log.Logger)
authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger)
authHandler.SetAudit(auditSvc)
oidcHandler := handler.NewOIDCHandler(cfg.Auth.OIDC, authManager)
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
vulnerabilityHandler.SetTaskStateProvider(agentHandler)
Expand Down Expand Up @@ -624,6 +625,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
setupRoutes(
router,
authHandler,
oidcHandler,
agentHandler,
monitorHandler,
notificationHandler,
Expand Down Expand Up @@ -930,6 +932,7 @@ func (a *App) RestartRobotConnections() {
func setupRoutes(
router *gin.Engine,
authHandler *handler.AuthHandler,
oidcHandler *handler.OIDCHandler,
agentHandler *handler.AgentHandler,
monitorHandler *handler.MonitorHandler,
notificationHandler *handler.NotificationHandler,
Expand Down Expand Up @@ -968,6 +971,9 @@ func setupRoutes(
loginRL := security.NewRateLimiter(10, 1*time.Minute)
{
authRoutes.POST("/login", security.RateLimitMiddleware(loginRL), authHandler.Login)
authRoutes.GET("/oidc/config", oidcHandler.Config)
authRoutes.GET("/oidc/login", security.RateLimitMiddleware(loginRL), oidcHandler.Login)
authRoutes.GET("/oidc/callback", security.RateLimitMiddleware(loginRL), oidcHandler.Callback)
authRoutes.POST("/logout", security.AuthMiddleware(authManager), authHandler.Logout)
authRoutes.POST("/change-password", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), authHandler.ChangePassword)
authRoutes.GET("/validate", security.AuthMiddleware(authManager), authHandler.Validate)
Expand Down
32 changes: 31 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1361,7 +1361,37 @@ func normalizeHitlModeForPrompt(mode string) string {
}

type AuthConfig struct {
SessionDurationHours int `yaml:"session_duration_hours" json:"session_duration_hours"`
SessionDurationHours int `yaml:"session_duration_hours" json:"session_duration_hours"`
OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"`
}
type OIDCConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
AllowHTTP bool `yaml:"allow_http" json:"allow_http"`
Issuer string `yaml:"issuer" json:"issuer"`
DiscoveryURL string `yaml:"discovery_url" json:"discovery_url"`
ClientID string `yaml:"client_id" json:"client_id"`
ClientSecret string `yaml:"client_secret" json:"-"`
RedirectURI string `yaml:"redirect_uri" json:"redirect_uri"`
LoginLabel string `yaml:"login_label" json:"login_label"`
Claims OIDCClaimsConfig `yaml:"claims" json:"claims"`
}

type OIDCClaimsConfig struct {
Username string `yaml:"username" json:"username"`
DisplayName string `yaml:"display_name" json:"display_name"`
}

func (c OIDCConfig) UsernameClaim() string {
if c.Claims.Username != "" {
return c.Claims.Username
}
return "preferred_username"
}
func (c OIDCConfig) DisplayNameClaim() string {
if c.Claims.DisplayName != "" {
return c.Claims.DisplayName
}
return "name"
}

// MonitorConfig MCP 状态监控(tool_executions)保留策略。
Expand Down
2 changes: 2 additions & 0 deletions internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
}

h.manager.RevokeToken(token)
// Remove the OIDC login cookie alongside the revoked server-side session.
http.SetCookie(c.Writer, &http.Cookie{Name: "auth_token", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
if h.audit != nil {
h.audit.Record(c, audit.Entry{
Category: "auth",
Expand Down
Loading