137 lines
4.9 KiB
Go
137 lines
4.9 KiB
Go
// 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
|
||
}
|