diff --git a/config.example.yaml b/config.example.yaml index e5151f896..ac64a4b49 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 发现地址不符合 /.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(错误) diff --git a/docs/zh-CN/oidc-login.md b/docs/zh-CN/oidc-login.md new file mode 100644 index 000000000..1b48c32cc --- /dev/null +++ b/docs/zh-CN/oidc-login.md @@ -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` 是可选的;默认使用 `/.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 连接仅供受控测试环境使用。 diff --git a/go.mod b/go.mod index 894b38642..60d236911 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/go.sum b/go.sum index c10b3fa34..231d14b41 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go index c7ce58969..f46b00489 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) @@ -624,6 +625,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error setupRoutes( router, authHandler, + oidcHandler, agentHandler, monitorHandler, notificationHandler, @@ -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, @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index dc5c91654..09423af2c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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)保留策略。 diff --git a/internal/handler/auth.go b/internal/handler/auth.go index c996af78f..1657e8b0e 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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", diff --git a/internal/handler/oidc.go b/internal/handler/oidc.go new file mode 100644 index 000000000..9b12b23e6 --- /dev/null +++ b/internal/handler/oidc.go @@ -0,0 +1,267 @@ +package handler + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/security" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" +) + +type OIDCHandler struct { + cfg config.OIDCConfig + manager *security.AuthManager + client *http.Client +} + +func NewOIDCHandler(cfg config.OIDCConfig, manager *security.AuthManager) *OIDCHandler { + if secret := os.Getenv("CYBERSTRIKE_OIDC_CLIENT_SECRET"); secret != "" { + cfg.ClientSecret = secret + } + return &OIDCHandler{cfg: cfg, manager: manager, client: &http.Client{Timeout: 10 * time.Second}} +} + +type discoveryDocument struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserinfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` +} + +func (h *OIDCHandler) discovery(ctx context.Context) (discoveryDocument, error) { + var d discoveryDocument + issuer := strings.TrimRight(h.cfg.Issuer, "/") + if !h.allowedOIDCURL(issuer) { + return d, errors.New("OIDC issuer requires HTTPS unless allow_http is enabled") + } + discoveryURL := strings.TrimSpace(h.cfg.DiscoveryURL) + if discoveryURL == "" { + discoveryURL = issuer + "/.well-known/openid-configuration" + } + if !h.allowedOIDCURL(discoveryURL) { + return d, errors.New("OIDC discovery URL requires HTTPS unless allow_http is enabled") + } + err := h.getJSON(ctx, discoveryURL, "", &d) + if err != nil { + return d, err + } + if d.Issuer != issuer { + return d, errors.New("OIDC issuer mismatch") + } + for _, v := range []string{d.AuthorizationEndpoint, d.TokenEndpoint, d.UserinfoEndpoint, d.JWKSURI} { + if v != "" && !h.allowedOIDCURL(v) { + return d, errors.New("OIDC endpoint requires HTTPS unless allow_http is enabled") + } + } + return d, nil +} + +// allowedOIDCURL only relaxes transport scheme in explicitly opted-in environments. +func (h *OIDCHandler) allowedOIDCURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || u.Fragment != "" { + return false + } + return u.Scheme == "https" || (h.cfg.AllowHTTP && u.Scheme == "http") +} + +func (h *OIDCHandler) callbackCookieSecure() bool { + u, err := url.Parse(h.cfg.RedirectURI) + return err == nil && u.Scheme == "https" +} +func (h *OIDCHandler) getJSON(ctx context.Context, endpoint, bearer string, out interface{}) error { + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return err + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + res, err := h.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != 200 { + return fmt.Errorf("OIDC endpoint returned %d", res.StatusCode) + } + return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(out) +} +func randomURLToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} +func (h *OIDCHandler) Config(c *gin.Context) { + label := strings.TrimSpace(h.cfg.LoginLabel) + if label == "" { + label = "Sign in with SSO" + } + c.JSON(http.StatusOK, gin.H{"enabled": h.cfg.Enabled, "login_label": label}) +} +func (h *OIDCHandler) Login(c *gin.Context) { + if !h.cfg.Enabled { + c.AbortWithStatus(404) + return + } + if h.cfg.ClientID == "" || h.cfg.ClientSecret == "" || !h.allowedOIDCURL(h.cfg.RedirectURI) { + c.AbortWithStatus(503) + return + } + d, err := h.discovery(c.Request.Context()) + if err != nil { + c.AbortWithStatus(502) + return + } + state, err := randomURLToken() + if err != nil { + c.AbortWithStatus(500) + return + } + nonce, err := randomURLToken() + if err != nil { + c.AbortWithStatus(500) + return + } + verifier, err := randomURLToken() + if err != nil { + c.AbortWithStatus(500) + return + } + // The transaction is kept in HttpOnly cookies; never persist OAuth secrets in query strings. + secure := h.callbackCookieSecure() + for key, value := range map[string]string{"oidc_state": state, "oidc_nonce": nonce, "oidc_verifier": verifier} { + http.SetCookie(c.Writer, &http.Cookie{Name: key, Value: value, Path: "/api/auth/oidc/", MaxAge: 300, Secure: secure, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + } + challenge := sha256.Sum256([]byte(verifier)) + q := url.Values{"response_type": {"code"}, "client_id": {h.cfg.ClientID}, "redirect_uri": {h.cfg.RedirectURI}, "scope": {"openid profile email"}, "state": {state}, "nonce": {nonce}, "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])}, "code_challenge_method": {"S256"}} + c.Redirect(302, d.AuthorizationEndpoint+"?"+q.Encode()) +} +func (h *OIDCHandler) Callback(c *gin.Context) { + if !h.cfg.Enabled { + c.AbortWithStatus(404) + return + } + cookies := map[string]string{} + for _, key := range []string{"oidc_state", "oidc_nonce", "oidc_verifier"} { + cookie, err := c.Request.Cookie(key) + if err == nil { + cookies[key] = cookie.Value + } + http.SetCookie(c.Writer, &http.Cookie{Name: key, Path: "/api/auth/oidc/", MaxAge: -1, Secure: h.callbackCookieSecure(), HttpOnly: true, SameSite: http.SameSiteLaxMode}) + } + state := c.Query("state") + if state == "" || len(cookies["oidc_state"]) != len(state) || subtle.ConstantTimeCompare([]byte(state), []byte(cookies["oidc_state"])) != 1 || cookies["oidc_nonce"] == "" || cookies["oidc_verifier"] == "" { + c.AbortWithStatus(401) + return + } + if c.Query("error") != "" || c.Query("code") == "" { + c.AbortWithStatus(401) + return + } + d, err := h.discovery(c.Request.Context()) + if err != nil { + c.AbortWithStatus(502) + return + } + form := url.Values{"grant_type": {"authorization_code"}, "code": {c.Query("code")}, "redirect_uri": {h.cfg.RedirectURI}, "client_id": {h.cfg.ClientID}, "client_secret": {h.cfg.ClientSecret}, "code_verifier": {cookies["oidc_verifier"]}} + req, err := http.NewRequestWithContext(c.Request.Context(), "POST", d.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + c.AbortWithStatus(500) + return + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res, err := h.client.Do(req) + if err != nil { + c.AbortWithStatus(502) + return + } + defer res.Body.Close() + if res.StatusCode != 200 { + c.AbortWithStatus(401) + return + } + var tokens struct { + IDToken string `json:"id_token"` + AccessToken string `json:"access_token"` + } + if json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&tokens) != nil || tokens.IDToken == "" { + c.AbortWithStatus(401) + return + } + claims, err := h.verifyIDToken(c.Request.Context(), d, tokens.IDToken, cookies["oidc_nonce"]) + if err != nil { + c.AbortWithStatus(401) + return + } + if d.UserinfoEndpoint != "" && tokens.AccessToken != "" { + var info map[string]interface{} + if err = h.getJSON(c.Request.Context(), d.UserinfoEndpoint, tokens.AccessToken, &info); err != nil { + c.AbortWithStatus(502) + return + } + if info["sub"] != claims["sub"] { + c.AbortWithStatus(401) + return + } + for k, v := range info { + claims[k] = v + } + } + username, _ := claims[h.cfg.UsernameClaim()].(string) + display, _ := claims[h.cfg.DisplayNameClaim()].(string) + subject, _ := claims["sub"].(string) + if subject == "" { + c.AbortWithStatus(401) + return + } + token, expiry, err := h.manager.AuthenticateOIDC(username, display, d.Issuer, subject) + if err != nil { + c.AbortWithStatusJSON(403, gin.H{"error": "OIDC account not authorized or username conflict"}) + return + } + http.SetCookie(c.Writer, &http.Cookie{Name: "auth_token", Value: token, Path: "/", Expires: expiry, Secure: h.callbackCookieSecure(), HttpOnly: true, SameSite: http.SameSiteLaxMode}) + c.Redirect(302, "/") +} + +// verifyIDToken delegates JWT parsing, JWKS key selection, signature and +// standard OIDC claim checks to the maintained go-oidc verifier. +func (h *OIDCHandler) verifyIDToken(ctx context.Context, d discoveryDocument, raw, nonce string) (map[string]interface{}, error) { + if d.JWKSURI == "" { + return nil, errors.New("OIDC JWKS URI is missing") + } + ctx = oidc.ClientContext(ctx, h.client) + verifier := oidc.NewVerifier(d.Issuer, oidc.NewRemoteKeySet(ctx, d.JWKSURI), &oidc.Config{ + ClientID: h.cfg.ClientID, + SupportedSigningAlgs: []string{"RS256"}, + }) + idToken, err := verifier.Verify(ctx, raw) + if err != nil { + return nil, err + } + var claims map[string]interface{} + if err := idToken.Claims(&claims); err != nil { + return nil, err + } + if idToken.Nonce != nonce || nonce == "" { + return nil, errors.New("OIDC nonce mismatch") + } + return claims, nil +} diff --git a/internal/handler/oidc_test.go b/internal/handler/oidc_test.go new file mode 100644 index 000000000..8e8ca54c6 --- /dev/null +++ b/internal/handler/oidc_test.go @@ -0,0 +1,135 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "cyberstrike-ai/internal/config" + "github.com/gin-gonic/gin" +) + +func TestOIDCDefaultDiscoveryURL(t *testing.T) { + var issuer string + var requestedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath = r.URL.Path + if r.URL.Path != "/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(discoveryDocument{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + JWKSURI: issuer + "/jwks", + }) + })) + defer server.Close() + issuer = server.URL + + h := NewOIDCHandler(config.OIDCConfig{AllowHTTP: true, Issuer: issuer}, nil) + d, err := h.discovery(context.Background()) + if err != nil { + t.Fatalf("default discovery failed: %v", err) + } + if requestedPath != "/.well-known/openid-configuration" || d.Issuer != issuer { + t.Fatalf("unexpected discovery path %q or issuer %q", requestedPath, d.Issuer) + } +} + +func TestOIDCCustomDiscoveryURL(t *testing.T) { + const issuer = "http://issuer.example.test" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/oauth2/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(discoveryDocument{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + JWKSURI: issuer + "/jwks", + }) + })) + defer server.Close() + + h := NewOIDCHandler(config.OIDCConfig{ + AllowHTTP: true, + Issuer: issuer, + DiscoveryURL: server.URL + "/api/oauth2/.well-known/openid-configuration", + }, nil) + d, err := h.discovery(context.Background()) + if err != nil { + t.Fatalf("custom discovery failed: %v", err) + } + if d.Issuer != issuer { + t.Fatalf("unexpected issuer: %s", d.Issuer) + } +} + +func TestOIDCRejectsHTTPDiscoveryByDefault(t *testing.T) { + h := NewOIDCHandler(config.OIDCConfig{ + Issuer: "https://issuer.example.test", + DiscoveryURL: "http://issuer.example.test/.well-known/openid-configuration", + }, nil) + if _, err := h.discovery(context.Background()); err == nil { + t.Fatal("expected HTTP discovery to be rejected") + } +} + +func TestOIDCConfigLoginLabel(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewOIDCHandler(config.OIDCConfig{Enabled: true, LoginLabel: "Company SSO"}, nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/auth/oidc/config", nil) + h.Config(c) + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", w.Code) + } + var response struct { + Enabled bool `json:"enabled"` + Label string `json:"login_label"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if !response.Enabled || response.Label != "Company SSO" { + t.Fatalf("unexpected config response: %+v", response) + } +} + +func TestOIDCCookieSecureBasedOnRedirect(t *testing.T) { + for _, tc := range []struct { + uri string + secure bool + }{ + {"https://example.test/api/auth/oidc/callback", true}, + {"http://localhost:8082/api/auth/oidc/callback", false}, + } { + h := NewOIDCHandler(config.OIDCConfig{RedirectURI: tc.uri}, nil) + if h.callbackCookieSecure() != tc.secure { + t.Errorf("%s: expected secure=%t", tc.uri, tc.secure) + } + } +} + +func TestOIDCConfigDefaultLoginLabel(t *testing.T) { + h := NewOIDCHandler(config.OIDCConfig{Enabled: true}, nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/auth/oidc/config", nil) + h.Config(c) + var response struct { + Label string `json:"login_label"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Label != "Sign in with SSO" { + t.Fatalf("unexpected default label: %q", response.Label) + } +} diff --git a/internal/security/auth_manager.go b/internal/security/auth_manager.go index 48f25f772..02d3f6719 100644 --- a/internal/security/auth_manager.go +++ b/internal/security/auth_manager.go @@ -1,8 +1,11 @@ package security import ( + "crypto/sha256" "database/sql" + "encoding/hex" "errors" + "fmt" "strings" "sync" "time" @@ -305,3 +308,61 @@ func allPermissions() map[string]bool { } return out } + +// AuthenticateOIDC resolves a provisioned external account and issues an existing local session. +// A stable issuer+subject fingerprint is stored in the existing password_hash column. +// Local accounts and built-in admin can never be linked by username alone. +func (a *AuthManager) AuthenticateOIDC(username, displayName, issuer, subject string) (string, time.Time, error) { + username = strings.TrimSpace(strings.ToLower(username)) + if username == "" || len(username) > 64 || username == "admin" || strings.ContainsAny(username, "\x00\r\n") || issuer == "" || subject == "" { + return "", time.Time{}, errors.New("invalid OIDC identity") + } + fingerprint := sha256.Sum256([]byte(issuer + "\x00" + subject)) + marker := "!oidc:" + hex.EncodeToString(fingerprint[:]) + a.mu.RLock() + db := a.db + a.mu.RUnlock() + if db == nil { + return "", time.Time{}, errors.New("RBAC store unavailable") + } + user, err := db.GetRBACUserByUsername(username) + if err == sql.ErrNoRows { + user, err = db.CreateRBACUser(username, displayName, marker, true, []string{database.RBACSystemRoleViewer}) + if err != nil { + user, err = db.GetRBACUserByUsername(username) + if err != nil { + return "", time.Time{}, err + } + } + } else if err != nil { + return "", time.Time{}, err + } + if !user.Enabled || user.IsBuiltin || user.PasswordHash != marker { + return "", time.Time{}, fmt.Errorf("OIDC identity conflicts with existing user") + } + if strings.TrimSpace(displayName) != "" && user.DisplayName != displayName { + if err = db.UpdateRBACUser(user.ID, displayName, nil, nil); err != nil { + return "", time.Time{}, err + } + user.DisplayName = displayName + } + for { + a.mu.RLock() + version := a.accessVersion + a.mu.RUnlock() + access, err := db.ResolveRBACAccess(user.ID) + if err != nil { + return "", time.Time{}, err + } + roles := make([]string, 0, len(access.Roles)) + for _, role := range access.Roles { + roles = append(roles, role.ID) + } + token := uuid.NewString() + expiry := time.Now().Add(a.sessionDuration) + session := Session{Token: token, ExpiresAt: expiry, UserID: user.ID, Username: user.Username, DisplayName: user.DisplayName, Roles: roles, Permissions: access.Permissions, PermissionScopes: access.PermissionScopes, Scope: access.Scope} + if a.publishSession(session, version) { + return token, expiry, nil + } + } +} diff --git a/web/static/css/oidc-login.css b/web/static/css/oidc-login.css new file mode 100644 index 000000000..3d7cb6d89 --- /dev/null +++ b/web/static/css/oidc-login.css @@ -0,0 +1,25 @@ +/* OIDC is an alternative sign-in method, visually secondary to local login. */ +.login-form .login-oidc-button { + display: block; + width: 100%; + margin-top: 12px; + padding: 11px 16px; + border: 1px solid var(--border-color, #cbd5e1); + border-radius: 10px; + background: var(--card-bg, transparent); + color: inherit; + font: inherit; + font-weight: 600; + line-height: 1.4; + text-align: center; + cursor: pointer; + transition: background-color .2s ease, border-color .2s ease; +} +.login-form .login-oidc-button:hover { + background: var(--bg-secondary, rgba(148, 163, 184, .12)); + border-color: var(--text-secondary, #94a3b8); +} +.login-form .login-oidc-button:focus-visible { + outline: 2px solid var(--primary-color, #2563eb); + outline-offset: 2px; +} diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 9a0bba7d6..e7af55760 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -721,7 +721,26 @@ function formatMarkdown(text, options) { return escapeHtml(raw).replace(/\n/g, '
'); } +async function setupOIDCLoginUI() { + try { + const response = await fetch('/api/auth/oidc/config', {credentials: 'same-origin'}); + if (!response.ok) return; + const oidcConfig = await response.json(); + if (!oidcConfig.enabled) return; + const form = document.getElementById('login-form'); + if (!form || document.getElementById('oidc-login-button')) return; + const button = document.createElement('button'); + button.id = 'oidc-login-button'; + button.type = 'button'; + button.textContent = (typeof oidcConfig.login_label === 'string' && oidcConfig.login_label.trim()) + ? oidcConfig.login_label.trim() : 'Sign in with SSO'; + button.className = 'login-oidc-button'; + button.addEventListener('click', () => { window.location.href = '/api/auth/oidc/login'; }); + form.appendChild(button); + } catch (error) { console.warn('OIDC config unavailable', error); } +} function setupLoginUI() { + setupOIDCLoginUI(); installPermissionClickGuard(); const loginForm = document.getElementById('login-form'); if (loginForm) { @@ -757,6 +776,21 @@ async function initializeApp() { } } + // OIDC callback supplies a secure HttpOnly cookie, validated by the server. + try { + const response = await fetch('/api/auth/validate', {credentials: 'same-origin'}); + if (response.ok) { + const result = await response.json(); + if (result.token) { + saveAuth(result.token, result.expires_at, result); + hideLoginOverlay(); + applyRBACToUI(); + resolveAuthPromises(true); + await bootstrapApp(); + return; + } + } + } catch (error) { console.warn('OIDC session validation failed', error); } clearAuthStorage(); showLoginOverlay(); } diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index 598bdf128..67849a28f 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -107,7 +107,7 @@ test('Agent 审查不进入人工审批弹窗、倒计时和项目计数', () => const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8'); assert.match(handler, /CreatePendingInterrupt\([\s\S]{0,260}reviewer string/); assert.match(handler, /reviewer != "audit_agent"[\s\S]{0,120}m\.pending\[id\] = p/); - assert.match(logsHandler, /ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'/); + assert.match(logsHandler, /COALESCE\(reviewer,'human'\)/); assert.match(logsHandler, /status = 'pending' AND COALESCE\(reviewer,'human'\) = 'human'/); assert.match(monitor, /function isAgentReviewedHitl\(data\)/); assert.match(monitor, /if \(!data\.resolved && !isAgentReviewedHitl\(data\)\)/); diff --git a/web/templates/index.html b/web/templates/index.html index 85ed32fbc..f43ebe636 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -40,6 +40,7 @@ })(); +