// Package middleware — общая InternalAuth для cross-service запросов // через portal-proxy. Каждый сервис до этого имел свой клон файла; теперь // один источник истины. // // Контракт: // - portal-proxy пробрасывает X-Internal-Key (общий с сервисом секрет), // X-User-Id (UUID логиниувшегося юзера), X-User-Name (для логов/UI), // X-User-Is-Admin ('1' если у юзера системная админ-роль), плюс // любые X-User-* кастомные заголовки (роли/scope/subordinates). // - middleware кладёт всё это в context'е, чтобы handler'ы и нижестоящие // middleware'ы читали без повторного парса заголовков. // // Если сервису нужны кастомные роли — ставим дополнительные middleware // поверх (см. WithCustomHeaders в этом же пакете). package middleware import ( "context" "crypto/subtle" "net" "net/http" "strings" ) // contextKey — typedef'нутый string чтобы не сталкиваться с другими пакетами, // которые могут писать в r.Context() свои ключи. type contextKey string const ( UserIDKey contextKey = "user_id" UserNameKey contextKey = "user_name" IsAdminKey contextKey = "is_admin" ClientIPKey contextKey = "client_ip" ) // InternalAuth возвращает middleware, который: // - сверяет X-Internal-Key с expectedKey (constant-time compare против // timing-атак), // - требует X-User-Id (без него запрос неавторизован), // - кладёт user_id, user_name, is_admin, client_ip в r.Context(). // // Если expectedKey пустой — сервис намеренно отключил internal API; все // запросы летят с 503. Это удобный «kill switch» для dev без выкатки. func InternalAuth(expectedKey string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if expectedKey == "" { http.Error(w, `{"error":"internal api disabled"}`, http.StatusServiceUnavailable) return } gotKey := r.Header.Get("X-Internal-Key") if len(gotKey) != len(expectedKey) || subtle.ConstantTimeCompare([]byte(gotKey), []byte(expectedKey)) != 1 { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } userID := r.Header.Get("X-User-Id") if userID == "" { http.Error(w, `{"error":"missing X-User-Id header"}`, http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), UserIDKey, userID) ctx = context.WithValue(ctx, UserNameKey, r.Header.Get("X-User-Name")) ctx = context.WithValue(ctx, IsAdminKey, r.Header.Get("X-User-Is-Admin") == "1") ctx = context.WithValue(ctx, ClientIPKey, extractClientIP(r)) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // WithCustomHeader — фабрика для сервисов, которым нужны дополнительные // header→context маппинги. Пример: candidates читает X-Candidates-Scope. // // Использование: // r.Use(middleware.InternalAuth(key)) // r.Use(middleware.WithCustomHeader("X-Candidates-Scope", scopeKey)) // // Хендлер потом достаёт через context.Value(scopeKey). func WithCustomHeader(headerName string, ctxKey any) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { v := r.Header.Get(headerName) ctx := r.Context() if v != "" { ctx = context.WithValue(ctx, ctxKey, v) } next.ServeHTTP(w, r.WithContext(ctx)) }) } } func GetUserID(ctx context.Context) string { if v, ok := ctx.Value(UserIDKey).(string); ok { return v } return "" } func GetUserName(ctx context.Context) string { if v, ok := ctx.Value(UserNameKey).(string); ok { return v } return "" } func IsAdmin(ctx context.Context) bool { if v, ok := ctx.Value(IsAdminKey).(bool); ok { return v } return false } func GetClientIP(ctx context.Context) string { if v, ok := ctx.Value(ClientIPKey).(string); ok { return v } return "" } // extractClientIP читает X-Forwarded-For (берём левый — это исходный // клиент по RFC 7239), fallback на RemoteAddr без порта. func extractClientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if comma := strings.IndexByte(xff, ','); comma >= 0 { xff = xff[:comma] } if ip := strings.TrimSpace(xff); ip != "" { return ip } } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host }