init: portal-common (db + middleware + portal client)
This commit is contained in:
136
middleware/internal_auth.go
Normal file
136
middleware/internal_auth.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user