Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c133913
docs(spec6): webchat 前端一等公民化待办工作清单
hotplex-ai Jun 18, 2026
0e2beea
feat(webchat): implement multitenant ui, login flow, and settings drawer
hotplex-ai Jun 18, 2026
d275adf
fix(security): address 5 arch-review findings (P0-P2)
hotplex-ai Jun 18, 2026
41c34a0
fix(security): log cookie secret persist failure for diagnostics
hotplex-ai Jun 18, 2026
9ee17f8
chore(assets): remove unused root-level asset files
hotplex-ai Jun 18, 2026
39deac4
feat(make): inline dev-build into make dev for fresh binary
hotplex-ai Jun 18, 2026
7d0e022
feat(webchat): enable cookie auth for external frontend and dev mode
hotplex-ai Jun 18, 2026
4752ef4
feat(webchat): support cross-origin cookie auth for dev frontend
hotplex-ai Jun 18, 2026
11f8d0a
docs(webchat): add login bootstrap guidance design spec
hotplex-ai Jun 18, 2026
adc4673
docs(webchat): add login bootstrap guidance implementation plan
hotplex-ai Jun 18, 2026
83b796d
feat(session): add UserWorkspaceStore.HasAdmin for bootstrap detection
hotplex-ai Jun 18, 2026
329d797
feat(gateway): add public GET /api/auth/bootstrap-status endpoint
hotplex-ai Jun 18, 2026
79c9022
feat(gateway): include first_login flag in login/accept-invite response
hotplex-ai Jun 18, 2026
9827884
feat(webchat): show bootstrap guide when no admin exists
hotplex-ai Jun 18, 2026
5736bee
feat(webchat): add invite entry guidance and ?invite prefilled
hotplex-ai Jun 18, 2026
ad12d9a
fix(webchat): surface backend error codes as friendly Chinese on logi…
hotplex-ai Jun 18, 2026
a55f17b
feat(webchat): first-login onboarding welcome card
hotplex-ai Jun 18, 2026
0be32fd
fix(webchat): repair cross-origin login redirect and console noise
hotplex-ai Jun 18, 2026
34bbfa0
test(security): align cookie tests with SameSite=None + loopback Secure
hotplex-ai Jun 18, 2026
d742cab
fix(webchat): correct workspace JSON wire contract to snake_case
hotplex-ai Jun 18, 2026
3642500
fix(webchat): address PR #762 review findings (P1×2 + P2×4 + P3)
Jun 19, 2026
29354cc
fix(webchat): PR #762 review round 2 (P2×2 + P3 confirm)
Jun 19, 2026
7c21ee7
feat(webchat): workspace management first-class pages + agent-configs…
Jun 19, 2026
95e690d
feat(webchat): migrate members/profile to dedicated pages, remove Set…
Jun 19, 2026
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
19 changes: 18 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,23 @@ run: build
@./$(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH) \
gateway start -c $(CONFIG_DIR)/config-dev.yaml

# dev-build: 轻量构建,跳过 swagger,仅保证 go:embed 资源存在 + Go 编译。
# 供 `make dev` 每次启动前自动产出最新二进制,避免 dev.sh 兜底提示。
dev-build:
@echo "$(BOLD)$(CYAN)Dev Build$(RESET) $(DIM)$(VERSION) · $(GIT_SHA) · $(GOOS)/$(GOARCH)$(RESET)"
@mkdir -p $(BUILD_DIR) $(LOG_DIR)
@$(MAKE) webchat-embed --no-print-directory
@if [ ! -f internal/docs/out/index.html ]; then \
echo " $(CYAN)Docs$(RESET)$(DIM) building from scratch...$(RESET)"; \
$(MAKE) docs-build --no-print-directory; \
else \
echo " $(DIM)Docs ✓ cached$(RESET)"; \
fi
@echo " $(CYAN)Compiling$(RESET)$(DIM) Go binary...$(RESET)"
@CGO_ENABLED=0 go build $(BUILD_OPTS) -ldflags="$(LDFLAGS)" \
-o $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH) $(MAIN_PATH)
@echo " $(GREEN)✓$(RESET) $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)"

# ─────────────────────────────────────────────────────────────────────────────
# Test
# ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -210,7 +227,7 @@ dev: dev-start
@printf " make %-12s %s\n" "dev-stop" "Stop all"
@echo ""

dev-start:
dev-start: dev-build
@$(MAKE) gateway-start
@$(MAKE) webchat-dev || echo " $(YELLOW)⚠$(RESET) Webchat skipped (run 'cd webchat && pnpm install' to fix)"

Expand Down
129 changes: 0 additions & 129 deletions assets/architecture.svg

This file was deleted.

Binary file removed assets/banner.png
Binary file not shown.
Binary file removed assets/bot_avatar_white.png
Binary file not shown.
11 changes: 8 additions & 3 deletions cmd/hotplex/gateway_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,10 +415,11 @@ func runGateway(configPath string, devMode bool, stopCh <-chan struct{}) (err er
}
}

// Cookie auth: created when webchat is enabled for same-origin browser authentication.
// Cookie auth: created when webchat is enabled or when WebChat address is configured
// (supporting external dev/production frontends), or when running in devMode.
var cookieAuth *security.CookieAuth
if cfg.WebChat.Enabled {
ca, err := security.NewCookieAuth()
if cfg.WebChat.Enabled || cfg.WebChat.Addr != "" || devMode {
ca, err := security.NewCookieAuth(cfg.Security.CookieSecret)
if err != nil {
return fmt.Errorf("create cookie auth: %w", err)
}
Expand Down Expand Up @@ -880,6 +881,10 @@ func (s *gatewayStores) close(log *slog.Logger) {
log.Warn("gateway: event collector close", "err", err)
}
}
// Stop DBResolver's background cleanup goroutine before closing DB connections.
if s.dbResolver != nil {
s.dbResolver.Close()
}
// For SQLite: EventStore.Close is a no-op (ownsDB=false); session store owns the shared connection.
if s.session != nil {
if err := s.session.Close(); err != nil {
Expand Down
56 changes: 54 additions & 2 deletions cmd/hotplex/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,27 +199,79 @@ func setupRoutes(
}
}

// bootstrap-status is intentionally registered OUTSIDE the CookieAuth-gated
// auth block below: it must stay reachable before any admin exists (the very
// state the login page needs to detect). Only requires the workspace store.
if deps.WorkspaceStore != nil {
mux.Handle("GET /api/auth/bootstrap-status", corsMw(gateway.BootstrapStatus(deps.WorkspaceStore)))
mux.Handle("OPTIONS /api/auth/bootstrap-status", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
}

// WebChat multi-tenant auth endpoints (spec ① + spec ④).
// Wired when cookieAuth is available (webchat enabled).
if deps.CookieAuth != nil && deps.WorkspaceStore != nil {
// Account-login handlers (spec ①): requires LocalAccountProvider.
// LocalAccountProvider is created lazily from WorkspaceStore + bcrypt cost.
lap := security.NewLocalAccountProvider(deps.WorkspaceStore, security.BcryptCostDefault)
auth.SetIdentityProvider(lap)

authHandlers := gateway.NewAuthHandlers(auth, deps.CookieAuth, deps.WorkspaceStore, lap)
mux.Handle("POST /api/auth/login", corsMw(http.HandlerFunc(authHandlers.Login)))
mux.Handle("POST /api/auth/logout", corsMw(http.HandlerFunc(authHandlers.Logout)))
mux.Handle("GET /api/auth/me", corsMw(http.HandlerFunc(authHandlers.Me)))
mux.Handle("POST /api/auth/accept-invite", corsMw(http.HandlerFunc(authHandlers.AcceptInvite)))
log.Info("auth endpoints registered", "channels", "login,logout,me,accept-invite")

// OAuth SSO handlers (spec ④): requires OAuthManager with providers.
// App-level Admin endpoints
mux.Handle("POST /api/admin/invitations", corsMw(http.HandlerFunc(authHandlers.AdminCreateInvitation)))
mux.Handle("GET /api/admin/invitations", corsMw(http.HandlerFunc(authHandlers.AdminListInvitations)))
mux.Handle("DELETE /api/admin/invitations/{id}", corsMw(http.HandlerFunc(authHandlers.AdminDeleteInvitation)))
mux.Handle("GET /api/admin/users", corsMw(http.HandlerFunc(authHandlers.AdminListUsers)))
mux.Handle("PATCH /api/admin/users/{id}", corsMw(http.HandlerFunc(authHandlers.AdminUpdateUserStatus)))

// OPTIONS preflight handlers for Auth & Admin APIs
mux.Handle("OPTIONS /api/auth/login", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/auth/logout", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/auth/me", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/auth/accept-invite", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/admin/invitations", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/admin/invitations/", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/admin/invitations/{id}", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/admin/users", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/admin/users/", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/admin/users/{id}", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))

// Workspaces CRUD endpoints
wsHandlers := gateway.NewWorkspaceHandlers(deps.WorkspaceStore, deps.CookieAuth, auth)
mux.Handle("POST /api/workspaces", corsMw(http.HandlerFunc(wsHandlers.Create)))
mux.Handle("GET /api/workspaces", corsMw(http.HandlerFunc(wsHandlers.List)))
mux.Handle("GET /api/workspaces/{id}", corsMw(http.HandlerFunc(wsHandlers.Get)))
mux.Handle("PATCH /api/workspaces/{id}", corsMw(http.HandlerFunc(wsHandlers.Update)))
mux.Handle("DELETE /api/workspaces/{id}", corsMw(http.HandlerFunc(wsHandlers.Delete)))

// OPTIONS preflight handlers for Workspaces API
mux.Handle("OPTIONS /api/workspaces", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/workspaces/", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
mux.Handle("OPTIONS /api/workspaces/{id}", corsMw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))

log.Info("auth, admin, and workspaces endpoints registered", "channels", "login,logout,me,accept-invite,workspaces,admin")

// OAuth SSO handlers (spec ④): when OAuthManager has providers,
// register the full SSO flow (providers list + login + callback).
if deps.OAuthManager != nil && deps.OAuthManager.HasProviders() {
oauthHandlers := gateway.NewOAuthHandlers(deps.OAuthManager, deps.CookieAuth, deps.WorkspaceStore, log)
mux.Handle("GET /api/auth/oauth/providers", corsMw(http.HandlerFunc(oauthHandlers.Providers)))
mux.Handle("GET /api/auth/oauth/{provider}/login", http.HandlerFunc(oauthHandlers.Login))
mux.Handle("GET /api/auth/oauth/{provider}/callback", http.HandlerFunc(oauthHandlers.Callback))
// Note: login/callback are redirect flows, CORS not needed (browser navigates directly).
log.Info("oauth SSO endpoints registered", "providers", deps.OAuthManager.List())
} else {
// Always expose providers discovery so a cross-origin browser gets
// 200 + CORS headers instead of a CORS-masked 404 spamming the login
// console when SSO is unconfigured. Returns an empty list.
mux.Handle("GET /api/auth/oauth/providers", corsMw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("[]"))
})))
}
}

Expand Down
8 changes: 8 additions & 0 deletions configs/config-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ webchat:
addr: "127.0.0.1:3000"
enabled: false # dev mode uses next dev server for hot reload

# Pin specific dev origins (not "*") so the gateway emits
# Access-Control-Allow-Credentials — required for cross-origin cookie auth
# (webchat login flow). Next dev server may surface as 127.0.0.1 or localhost.
security:
allowed_origins:
- "http://127.0.0.1:3000"
- "http://localhost:3000"

session:
retention_period: 24h
max_concurrent: 100
Expand Down
62 changes: 62 additions & 0 deletions docs/security/TODO-security-arch-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Security 模块架构审计 TODO

> **来源**: Architecture Review Cycle 205 — [GitHub Issue #761](https://github.com/hrygo/hotplex/issues/761)
> **模块**: `internal/security`
> **方面**: resource-mgmt, performance, scalability

---

## P0 — 立即修复

### 1. 登录用户名枚举时序攻击

- **文件**: `local_account_provider.go:43-59`
- **问题**: 用户不存在时立即返回(0ms),密码错误时执行 bcrypt 约 200ms,攻击者可通过响应时间差异枚举有效用户名
- [x] 用户不存在或无密码哈希时,执行 dummy `bcrypt.CompareHashAndPassword` 对齐耗时
- [x] 添加测试验证存在与不存在用户的登录响应时间统计不可区分

### 2. API Key 验证 O(N) 循环分配

- **文件**: `auth.go:158-173`
- **问题**: `authenticateKey` 在每次 HTTP/WS 请求的热路径中执行 `[]byte` 转换 + 顺序遍历,堆分配压力大,且 early return 破坏常量时间特性
- [x] 在 startup/reload/CRUD 时预计算所有 API Key 的 SHA256 哈希,存入 `map[[32]byte]bool`
- [x] `authenticateKey` 改为一次 hash + map 查找(O(1))
- [x] 验证所有 authenticator 单元测试通过

---

## P1 — 尽快修复

### 3. OIDC Discovery 无超时挂起

- **文件**: `oauth_provider.go:60-64`
- **问题**: `NewOAuthProvider` 调用 `oidc.NewProvider` 时使用默认 `http.DefaultClient`(无超时),若 IdP 端点不可达则配置热重载永久阻塞
- [x] 通过 `oidc.ClientContext` 注入 `&http.Client{Timeout: 10 * time.Second}`
- [x] 添加测试验证慢速/不可达 issuer 在超时窗口内中止

### 4. DBResolver 缓存穿透 DoS

- **文件**: `apikey_resolver.go:93-123`
- **问题**: 无效 API Key 查询返回 `sql.ErrNoRows` 时不缓存负结果,攻击者可用无效 Key 绕过缓存直接打 DB
- [x] 对 `sql.ErrNoRows` 缓存负结果(TTL 5 秒)
- [x] 添加测试验证连续无效 Key 请求在 TTL 内只查询 DB 一次

---

## P2 — 计划修复

### 5. DBResolver 缓存内存泄漏

- **文件**: `apikey_resolver.go:61-123`
- **问题**: `sync.Map` 缓存仅被动淘汰(再次查询时才删除过期条目),Key 轮换场景下过期条目永久驻留
- [x] 实现后台清理 goroutine(ticker 定期扫描删除过期条目)
- [x] 确保清理 goroutine 在系统关闭时干净退出
- [x] 添加测试验证过期条目被自动清理

---

## 验证清单

- [x] `go test -v ./internal/security/...` 全部通过
- [ ] `golangci-lint run ./internal/security/...` 无新警告
- [ ] `make test` 无回归
Loading