init: portal-common (db + middleware + portal client)

This commit is contained in:
Grendgi
2026-05-20 14:00:06 +03:00
commit db1660a390
6 changed files with 604 additions and 0 deletions

136
middleware/internal_auth.go Normal file
View 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
}