v0.1.16: JWT auth via nubes API, auto-provisioning, UI login
- app/auth/jwt.go: ParseJWTClaims, TenantIDFromSub (sless-compatible SHA256), PingNubesAPI - app/admin/admin.go: POST /ui/api/auth endpoint, jwtMiddleware for /ui/api/* - app/tenant/tenant_store.go: NubesSub/Email fields, GetBySub, CreateFromJWT - app/ui/index.html: login page, email in navbar, JWT session in localStorage - deployments/k8s/deployment.yaml: v0.1.16, NUBES_ENDPOINT env - doc/decisions/resource-limits-plan.md: 20 vulnerabilities audit - Fix: /ui/api/auth moved to subrouter (gorilla/mux PathPrefix conflict)
This commit is contained in:
+113
-7
@@ -1,15 +1,18 @@
|
||||
// app/admin/admin.go
|
||||
// Admin API handlers for shared-sqs management
|
||||
// Created: 2026-04-09
|
||||
// Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений
|
||||
// Updated: 2026-04-10 — JWT auth через nubes API, auto-provisioning тенантов
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/auth"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/tenant"
|
||||
|
||||
@@ -33,13 +36,18 @@ func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
||||
|
||||
// Handler — admin API handler, holds TenantStore и admin token
|
||||
type Handler struct {
|
||||
store *tenant.TenantStore
|
||||
adminToken string
|
||||
store *tenant.TenantStore
|
||||
adminToken string
|
||||
nubesEndpoint string // URL nubes API для валидации JWT (напр. https://deck-api-test.ngcloud.ru/api/v1)
|
||||
}
|
||||
|
||||
// NewHandler — создаёт admin handler
|
||||
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
||||
return &Handler{store: store, adminToken: adminToken}
|
||||
nubesEndpoint := os.Getenv("NUBES_ENDPOINT")
|
||||
if nubesEndpoint == "" {
|
||||
nubesEndpoint = "https://deck-api-test.ngcloud.ru/api/v1"
|
||||
}
|
||||
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
|
||||
}
|
||||
|
||||
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
||||
@@ -59,11 +67,16 @@ func (h *Handler) RegisterRoutes(r *mux.Router) {
|
||||
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
|
||||
// Дублируют admin API, но доступны без bearer token для удобства демо
|
||||
// TODO: убрать или заменить на session-auth перед production
|
||||
// RegisterPublicRoutes — маршруты для UI console.
|
||||
// POST /ui/api/auth — единственный публичный endpoint (принимает JWT, возвращает session).
|
||||
// Остальные /ui/api/* — защищены JWT middleware (токен в Authorization header).
|
||||
// ВАЖНО: все UI API routes на ОДНОМ subrouter PathPrefix("/ui/api") — иначе через nginx ingress
|
||||
// root-router HandleFunc конфликтует с PathPrefix subrouter (404 на POST).
|
||||
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
||||
ui := r.PathPrefix("/ui/api").Subrouter()
|
||||
// jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint
|
||||
ui.Use(h.jwtMiddleware)
|
||||
ui.HandleFunc("/auth", h.jwtAuth).Methods("POST")
|
||||
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||
ui.HandleFunc("/tenants", h.listTenants).Methods("GET")
|
||||
ui.HandleFunc("/tenants", h.createTenant).Methods("POST")
|
||||
@@ -92,6 +105,99 @@ func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// jwtAuth — POST /ui/api/auth — принимает JWT токен, валидирует через nubes API,
|
||||
// auto-provision тенанта если не существует, возвращает email + tenant info.
|
||||
// Это единственный публичный endpoint UI API.
|
||||
func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Token == "" {
|
||||
jsonErr(w, http.StatusBadRequest, "token is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Парсим JWT claims
|
||||
claims, err := auth.ParseJWTClaims(req.Token)
|
||||
if err != nil {
|
||||
log.Warnf("jwt auth: parse error: %v", err)
|
||||
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидируем через nubes API
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := auth.PingNubesAPI(ctx, h.nubesEndpoint, req.Token); err != nil {
|
||||
log.Warnf("jwt auth: nubes rejected token for sub=%s: %v", claims.Sub, err)
|
||||
jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-provisioning: создаём тенанта если не существует
|
||||
tenantID := auth.TenantIDFromSub(claims.Sub)
|
||||
t, err := h.store.CreateFromJWT(tenantID, claims.Sub, claims.Email, 10)
|
||||
if err != nil {
|
||||
log.Errorf("jwt auth: failed to create tenant: %v", err)
|
||||
jsonErr(w, http.StatusInternalServerError, "failed to provision tenant")
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("jwt auth: authenticated sub=%s email=%s tenant=%s", claims.Sub, claims.Email, t.ID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"email": claims.Email,
|
||||
"tenant_id": t.ID,
|
||||
"access_key": t.AccessKey,
|
||||
"secret_key": t.SecretKey,
|
||||
"max_queues": t.MaxQueues,
|
||||
"token": req.Token, // возвращаем для использования в последующих запросах
|
||||
})
|
||||
}
|
||||
|
||||
// jwtMiddleware — middleware для /ui/api/* endpoints.
|
||||
// Пропускает /ui/api/auth (публичный endpoint авторизации).
|
||||
// Проверяет Authorization: Bearer <jwt> заголовок.
|
||||
// Парсит JWT, проверяет что sub соответствует существующему тенанту.
|
||||
// НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth.
|
||||
func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// /ui/api/auth — публичный endpoint, пропускаем без проверки JWT
|
||||
if strings.HasSuffix(r.URL.Path, "/auth") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
jsonErr(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||
jsonErr(w, http.StatusUnauthorized, "invalid authorization format")
|
||||
return
|
||||
}
|
||||
token := parts[1]
|
||||
|
||||
claims, err := auth.ParseJWTClaims(token)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем что тенант существует (был создан при /ui/api/auth)
|
||||
_, ok := h.store.GetBySub(claims.Sub)
|
||||
if !ok {
|
||||
jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// createTenantRequest — тело запроса POST /admin/tenants
|
||||
type createTenantRequest struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
Reference in New Issue
Block a user