merge: fix/audit-b2-authz-hardening (audit batch 2: principal identity, docgov access, rate limit, compose secrets)
This commit is contained in:
@@ -95,7 +95,7 @@ services:
|
||||
KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required for main/production}
|
||||
AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true}
|
||||
AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-false}
|
||||
AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false}
|
||||
EMBEDDING_ENDPOINT_URL: ${EMBEDDING_ENDPOINT_URL:-}
|
||||
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-bge-m3}
|
||||
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_connections=150"]
|
||||
environment:
|
||||
POSTGRES_USER: ${SYSTEM_DB_USER:-app}
|
||||
POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:-test_secret_db_pass}
|
||||
POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:?required}
|
||||
POSTGRES_DB: ${SYSTEM_DB_NAME:-agent_system_test}
|
||||
ports:
|
||||
- "127.0.0.1:${SYSTEM_DB_PUBLISH_PORT:-5434}:5432"
|
||||
@@ -82,7 +82,7 @@ services:
|
||||
MAX_CONCURRENT_AGENTS: ${MAX_CONCURRENT_AGENTS:-8}
|
||||
DATA_DIR: /app/data
|
||||
# 独占测试业务与对话库
|
||||
APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-test_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable
|
||||
APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable
|
||||
# 共享知识库
|
||||
POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:?KB_DB_PASSWORD is required}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable
|
||||
PI_AGENT_RUNNER_URL: http://pi-agent:18866
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
RERANKER_MODEL: ${RERANKER_MODEL:-bge-reranker-v2-m3-free}
|
||||
RERANKER_API_KEY: ${RERANKER_API_KEY:-}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin-secret-password-123}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?required}
|
||||
AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true}
|
||||
AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
@@ -152,7 +152,7 @@ services:
|
||||
HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}"
|
||||
EVENTS_HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}"
|
||||
DATA_DIR: /app/data
|
||||
APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-test_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable
|
||||
APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable
|
||||
ports:
|
||||
- "127.0.0.1:${EVENTS_HTTP_PORT:-8089}:${EVENTS_HTTP_PORT:-8089}"
|
||||
healthcheck:
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestFAVDegradedReadDefaultOff(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPublisherEnableDefaultOff(t *testing.T) {
|
||||
if os.Getenv("FAV_PUBLISHER_ENABLE") == "1" {
|
||||
if os.Getenv("FAV_PUBLISHER_ENABLED") == "1" {
|
||||
t.Fatal("test env must not force publisher on")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) {
|
||||
return nil, errors.New("missing auth token")
|
||||
}
|
||||
|
||||
// handleAuthLogin 用户名密码登录
|
||||
// handleAuthLogin 用户名密码登录(限流:连续爆破触发 429)
|
||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
@@ -47,6 +47,17 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.loginLimiter != nil {
|
||||
key := s.loginLimiter.key(r, req.Username)
|
||||
if !s.loginLimiter.allow(key) {
|
||||
writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{
|
||||
"error_code": "ERR_LOGIN_RATE_LIMITED",
|
||||
"message": "登录尝试过于频繁,请稍后再试",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
session, err := s.authSvc.Login(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
@@ -62,11 +73,15 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.loginLimiter != nil {
|
||||
s.loginLimiter.reset(s.loginLimiter.key(r, req.Username))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(session)
|
||||
}
|
||||
|
||||
// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客)
|
||||
// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客,限流防批量注册)
|
||||
func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req auth.RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -74,6 +89,14 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.loginLimiter != nil && !s.loginLimiter.allow(s.loginLimiter.key(r, req.Username)) {
|
||||
writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{
|
||||
"error_code": "ERR_REGISTER_RATE_LIMITED",
|
||||
"message": "注册尝试过于频繁,请稍后再试",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := s.authSvc.Register(r.Context(), req)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusBadRequest, err.Error())
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"chat-agent-system/internal/auth"
|
||||
"chat-agent-system/internal/authz"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docgov"
|
||||
)
|
||||
|
||||
// failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。
|
||||
@@ -135,3 +136,65 @@ func (s *Server) requireProjectAccess(w http.ResponseWriter, r *http.Request, pr
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// requireDocGovDocAccess 校验当前用户可访问指定受控文档(经 doc_id 解析 project_id 后做项目成员校验)。
|
||||
func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request, docID string) (*auth.UserAccount, bool) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if s.gov == nil {
|
||||
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error_code": "ERR_DOCGOV_UNAVAILABLE",
|
||||
"message": "docgov engine not initialized",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
if s.isSystemAdminUser(r.Context(), user) {
|
||||
return user, true
|
||||
}
|
||||
doc, err := s.gov.GetDocument(r.Context(), docID)
|
||||
if err != nil || doc == nil {
|
||||
writeJSONResponse(w, http.StatusNotFound, map[string]string{
|
||||
"error_code": "ERR_DOCUMENT_NOT_FOUND",
|
||||
"message": "文档不存在或无权限访问",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
if doc.ProjectID <= 0 {
|
||||
// 无项目归属的文档仅管理员可见
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "无权访问该文档",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
if err := s.authorizer.RequireProjectAccess(r.Context(), doc.ProjectID, user.ID); err != nil {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "无权访问该文档所属项目",
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// resolveDocGovRole 服务端按文档职责分配解析 docgov 角色,不信任客户端自报角色。
|
||||
func (s *Server) resolveDocGovRole(ctx context.Context, docID string, user *auth.UserAccount) docgov.UserRole {
|
||||
if user == nil || s.gov == nil {
|
||||
return docgov.UserRole("")
|
||||
}
|
||||
doc, err := s.gov.GetDocument(ctx, docID)
|
||||
if err != nil || doc == nil {
|
||||
return docgov.UserRole("")
|
||||
}
|
||||
switch user.ID {
|
||||
case doc.AuthorID:
|
||||
return docgov.RoleAuthor
|
||||
case doc.ReviewerID:
|
||||
return docgov.RoleReviewer
|
||||
case doc.ApproverID:
|
||||
return docgov.RoleApprover
|
||||
}
|
||||
return docgov.UserRole("")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -48,6 +49,9 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
user, err := s.authenticateUser(r)
|
||||
if (err != nil || user == nil) && s.allowLegacyTestPrincipal() {
|
||||
if fallback, fbErr := s.authSvc.GetUserByID(r.Context(), 1); fbErr == nil && fallback != nil {
|
||||
slog.Warn("legacy_test_principal_fallback",
|
||||
"path", r.URL.Path, "method", r.Method,
|
||||
"remote_addr", r.RemoteAddr, "fallback_user_id", fallback.ID)
|
||||
user = fallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
|
||||
// handleGetChatTurn 查询回合状态(断线续接)
|
||||
func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
clientTurnID := r.PathValue("client_turn_id")
|
||||
sessionID := r.URL.Query().Get("session_id")
|
||||
if clientTurnID == "" || sessionID == "" {
|
||||
@@ -23,6 +27,11 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 归属校验:只允许查询本人会话的回合状态
|
||||
if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) {
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := ctxMgr.GetChatTurn(r.Context(), sessionID, clientTurnID)
|
||||
if errors.Is(err, ctxmgr.ErrChatTurnNotFound) {
|
||||
writeSafeError(w, r, http.StatusNotFound, "turn not found")
|
||||
|
||||
@@ -427,14 +427,26 @@ func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy s
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// handleGetUserContext 获取单用户私有上下文与个性化偏好
|
||||
// user_id 仅允许等于当前认证用户本人(或系统管理员),不再默认回落 user id=1。
|
||||
func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userIDStr := r.URL.Query().Get("user_id")
|
||||
projectIDStr := r.URL.Query().Get("project_id")
|
||||
sessionID := r.URL.Query().Get("session_id")
|
||||
|
||||
userID, _ := strconv.ParseInt(userIDStr, 10, 64)
|
||||
if userID <= 0 {
|
||||
userID = 1
|
||||
userID = user.ID
|
||||
}
|
||||
if userID != user.ID && !s.isSystemAdminUser(r.Context(), user) {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "无权查询其他用户的上下文",
|
||||
})
|
||||
return
|
||||
}
|
||||
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
|
||||
|
||||
@@ -544,6 +556,10 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.gov.GetDocument(r.Context(), docID)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error())
|
||||
@@ -583,7 +599,7 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, docgov.UserRole(req.UserRole), user.Name)
|
||||
doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error())
|
||||
return
|
||||
@@ -868,6 +884,10 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
comments, err := s.gov.ListComments(r.Context(), docID)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error())
|
||||
@@ -889,6 +909,7 @@ type AddCommentRequestDTO struct {
|
||||
}
|
||||
|
||||
// handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议
|
||||
// actor 身份一律取认证 principal,禁止请求体伪造 user_id/user_name/user_role。
|
||||
func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) {
|
||||
var req AddCommentRequestDTO
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -901,14 +922,19 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := s.requireDocGovDocAccess(w, r, req.DocID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
cType := docgov.CommentType(req.CommentType)
|
||||
if cType == "" {
|
||||
cType = docgov.CommentGeneral
|
||||
}
|
||||
|
||||
comment, err := s.gov.AddComment(
|
||||
r.Context(), req.DocID, req.UserID, req.UserName,
|
||||
docgov.UserRole(req.UserRole), req.TargetSection, cType, req.Content,
|
||||
r.Context(), req.DocID, user.ID, user.Name,
|
||||
s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content,
|
||||
)
|
||||
if err != nil {
|
||||
writeDocGovError(w, err)
|
||||
@@ -931,6 +957,10 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := s.gov.ListAuditLogs(r.Context(), docID)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error())
|
||||
@@ -945,14 +975,31 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// handleListChatSessions 分页查询用户/项目关联的历史会话列表
|
||||
// user_id 仅允许等于当前认证用户本人(或系统管理员),防止水平越权枚举他人会话。
|
||||
func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ctxMgr == nil {
|
||||
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userIDStr := r.URL.Query().Get("user_id")
|
||||
userID, _ := strconv.ParseInt(userIDStr, 10, 64)
|
||||
if userID > 0 && userID != user.ID && !s.isSystemAdminUser(r.Context(), user) {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "无权查询其他用户的会话列表",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 未显式传 user_id 时默认查本人,而不是任意/默认用户
|
||||
if userID <= 0 {
|
||||
userID = user.ID
|
||||
}
|
||||
projectIDStr := r.URL.Query().Get("project_id")
|
||||
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// loginRateLimiter 基于 IP+用户名的滑动窗口登录限流器(纯 stdlib,防爆破/撞库)。
|
||||
// 窗口内超过 maxAttempts 次尝试即拒绝,窗口过期后自动重置。
|
||||
type loginRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
maxAttempts int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func newLoginRateLimiter(maxAttempts int, window time.Duration) *loginRateLimiter {
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 10
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 5 * time.Minute
|
||||
}
|
||||
return &loginRateLimiter{
|
||||
attempts: make(map[string][]time.Time),
|
||||
maxAttempts: maxAttempts,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
// allow 判断 key 本次尝试是否放行;放行则记录一次尝试。
|
||||
func (l *loginRateLimiter) allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-l.window)
|
||||
|
||||
// 滑出窗口的尝试丢弃
|
||||
ts := l.attempts[key]
|
||||
kept := ts[:0]
|
||||
for _, t := range ts {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= l.maxAttempts {
|
||||
l.attempts[key] = kept
|
||||
return false
|
||||
}
|
||||
l.attempts[key] = append(kept, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// reset 认证成功后清空该 key 的计数(避免正常用户被自己的历史失败累积误伤)。
|
||||
func (l *loginRateLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
|
||||
// clientIP 取客户端 IP(优先 X-Forwarded-For 首跳,回落 RemoteAddr)。
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if first, _, _ := strings.Cut(xff, ","); strings.TrimSpace(first) != "" {
|
||||
return strings.TrimSpace(first)
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (l *loginRateLimiter) key(r *http.Request, username string) string {
|
||||
return clientIP(r) + "|" + strings.ToLower(strings.TrimSpace(username))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginRateLimiter_BlocksAfterMaxAttempts(t *testing.T) {
|
||||
l := newLoginRateLimiter(3, time.Minute)
|
||||
key := "1.2.3.4|alice"
|
||||
for i := 0; i < 3; i++ {
|
||||
if !l.allow(key) {
|
||||
t.Fatalf("attempt %d should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
if l.allow(key) {
|
||||
t.Fatalf("4th attempt within window should be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimiter_ResetClearsWindow(t *testing.T) {
|
||||
l := newLoginRateLimiter(2, time.Minute)
|
||||
key := "1.2.3.4|bob"
|
||||
l.allow(key)
|
||||
l.allow(key)
|
||||
if l.allow(key) {
|
||||
t.Fatalf("should be blocked before reset")
|
||||
}
|
||||
l.reset(key)
|
||||
if !l.allow(key) {
|
||||
t.Fatalf("should be allowed after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimiter_WindowExpiry(t *testing.T) {
|
||||
l := newLoginRateLimiter(1, 10*time.Millisecond)
|
||||
key := "1.2.3.4|carol"
|
||||
l.allow(key)
|
||||
if l.allow(key) {
|
||||
t.Fatalf("should be blocked within window")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if !l.allow(key) {
|
||||
t.Fatalf("should be allowed after window expiry")
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ type Server struct {
|
||||
vaultPublisherStarted int32
|
||||
vaultStop chan struct{}
|
||||
reminderStop chan struct{}
|
||||
loginLimiter *loginRateLimiter
|
||||
}
|
||||
|
||||
// NewFromRuntime attaches the HTTP adapter to an already-wired composition root.
|
||||
@@ -114,6 +115,8 @@ func NewFromRuntime(rt *app.Runtime) *Server {
|
||||
kb: rt.KB,
|
||||
sysadmin: rt.SysAdmin,
|
||||
intentPlans: rt.IntentPlans,
|
||||
// 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口
|
||||
loginLimiter: newLoginRateLimiter(10, 5*time.Minute),
|
||||
}
|
||||
s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz())
|
||||
s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog)
|
||||
|
||||
Reference in New Issue
Block a user