Files
portal-common/middleware/internal_auth.go

137 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}