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:
Naeel
2026-04-10 18:35:19 +03:00
parent 6fb160f8ae
commit b9d434bcc5
9 changed files with 854 additions and 25 deletions
+113 -7
View File
@@ -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"`
+99
View File
@@ -0,0 +1,99 @@
// Изменено: 2026-04-10
// jwt.go — JWT-парсинг и валидация через nubes API (deck-api).
// Извлекает sub (UUID пользователя) и email из JWT claims.
// Валидация: GET к nubes API с Bearer token → если 401/403 → отклонён.
// Подпись JWT НЕ проверяется — аналогично sless (trusted perimeter).
package auth
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// JWTClaims — минимальный набор claims из nubes JWT.
// Не все поля заполнены у каждого пользователя — email может быть пустым.
type JWTClaims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Exp int64 `json:"exp"`
Iss string `json:"iss"`
}
// ParseJWTClaims — декодирует JWT payload (base64url) и возвращает claims.
// Проверяет: структуру JWT (3 части), наличие sub, не истёк ли exp.
// Подпись НЕ проверяется — валидация через PingNubesAPI.
func ParseJWTClaims(token string) (*JWTClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts))
}
// JWT использует base64url без padding — добавляем
payload := parts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
// Пробуем StdEncoding на случай нестандартного токена
decoded, err = base64.StdEncoding.DecodeString(payload)
if err != nil {
return nil, fmt.Errorf("decode JWT payload: %w", err)
}
}
var claims JWTClaims
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil, fmt.Errorf("parse JWT claims: %w", err)
}
if claims.Sub == "" {
return nil, fmt.Errorf("JWT missing 'sub' claim")
}
if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
return nil, fmt.Errorf("JWT token expired")
}
return &claims, nil
}
// TenantIDFromSub — вычисляет tenant ID из JWT subject (sub claim).
// Алгоритм совместим с sless: SHA256(sub) → первые 8 байт → hex → "sless-{16hex}".
// Длина: 6 + 16 = 22 символа. Детерминирован, необратим.
// Почему "sless-" а не "ssq-": единый namespace для всех сервисов (IoT, funcs, SQS).
func TenantIDFromSub(sub string) string {
hash := sha256.Sum256([]byte(sub))
return fmt.Sprintf("sless-%x", hash[:8])
}
// PingNubesAPI — проверяет валидность токена запросом к nubes API.
// endpoint — базовый URL (например "https://deck-api-test.ngcloud.ru/api/v1").
// Логика: 401/403 → токен отклонён. Ошибка соединения → API недоступен.
// Любой другой HTTP статус → токен принят.
func PingNubesAPI(ctx context.Context, endpoint, token string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("build nubes ping request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("nubes API unreachable at %s: %w", endpoint, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("nubes API rejected token (HTTP %d)", resp.StatusCode)
}
return nil
}
+3 -1
View File
@@ -33,10 +33,12 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
adminHandler := admin.NewHandler(tenantStore, adminToken)
adminHandler.RegisterRoutes(r)
// UI public API — без auth, для встроенной console
// UI public API — JWT auth, для встроенной console
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
adminHandler.RegisterPublicRoutes(r)
// UI console — встроенный SPA, публичный доступ
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
// SQS API — tenant auth middleware оборачивает каждый handler отдельно.
+81 -2
View File
@@ -16,21 +16,24 @@ import (
// Tenant — модель тенанта shared-sqs.
// AccessKey используется как идентификатор в AWS Authorization header.
type Tenant struct {
ID string // уникальный идентификатор тенанта (t-<hex>)
ID string // уникальный идентификатор тенанта (sless-<hex> из JWT sub, или t-<hex> для legacy)
Name string // человекочитаемое имя
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
MaxQueues int // лимит очередей (0 = безлимит)
CreatedAt time.Time
Active bool
NubesSub string `json:"nubes_sub,omitempty"` // JWT sub claim (UUID пользователя nubes). Пусто для legacy тенантов.
Email string `json:"email,omitempty"` // Email из JWT. Для отображения в UI.
}
// TenantStore — потокобезопасное in-memory хранилище тенантов.
// Два индекса позволяют быстро искать как по ID (admin API), так и по AccessKey (auth middleware).
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth).
type TenantStore struct {
mu sync.RWMutex
byID map[string]*Tenant
byAccessKey map[string]*Tenant
bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim)
}
// NewTenantStore — создаёт пустое хранилище тенантов.
@@ -38,6 +41,7 @@ func NewTenantStore() *TenantStore {
return &TenantStore{
byID: make(map[string]*Tenant),
byAccessKey: make(map[string]*Tenant),
bySub: make(map[string]*Tenant),
}
}
@@ -69,6 +73,9 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
s.mu.Lock()
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
if t.NubesSub != "" {
s.bySub[t.NubesSub] = t
}
s.mu.Unlock()
// Сохраняем в Redis асинхронно — сериализуем здесь, вне мьютекса
@@ -139,6 +146,9 @@ func (s *TenantStore) Delete(id string) bool {
}
delete(s.byID, t.ID)
delete(s.byAccessKey, t.AccessKey)
if t.NubesSub != "" {
delete(s.bySub, t.NubesSub)
}
s.mu.Unlock()
// Удаляем из Redis асинхронно
@@ -153,9 +163,78 @@ func (s *TenantStore) LoadTenant(t *Tenant) {
s.mu.Lock()
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
if t.NubesSub != "" {
s.bySub[t.NubesSub] = t
}
s.mu.Unlock()
}
// GetBySub — поиск тенанта по NubesSub (JWT sub claim).
// Используется при JWT-авторизации через UI.
func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) {
s.mu.RLock()
t, ok := s.bySub[sub]
s.mu.RUnlock()
return t, ok
}
// CreateFromJWT — auto-provisioning тенанта из JWT claims.
// ID = TenantIDFromSub(sub) — совместим с sless namespace.
// Если тенант с таким sub уже существует — возвращает его (идемпотентно).
func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) (*Tenant, error) {
s.mu.Lock()
// Идемпотентность: если тенант с таким sub уже есть — возвращаем
if existing, ok := s.bySub[sub]; ok {
// Обновляем email если изменился
if email != "" && existing.Email != email {
existing.Email = email
}
s.mu.Unlock()
return existing, nil
}
accessKey, err := generateAccessKey()
if err != nil {
s.mu.Unlock()
return nil, fmt.Errorf("generate access key: %w", err)
}
secretKey, err := generateSecretKey()
if err != nil {
s.mu.Unlock()
return nil, fmt.Errorf("generate secret key: %w", err)
}
// Имя тенанта — email или sub (если email пустой)
name := email
if name == "" {
name = sub
}
t := &Tenant{
ID: tenantID,
Name: name,
AccessKey: accessKey,
SecretKey: secretKey,
MaxQueues: maxQueues,
CreatedAt: time.Now().UTC(),
Active: true,
NubesSub: sub,
Email: email,
}
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
s.bySub[t.NubesSub] = t
s.mu.Unlock()
// Сохраняем в Redis
if data, err := json.Marshal(t); err == nil {
persistence.SaveTenantRaw(t.ID, data)
}
return t, nil
}
// List — список всех тенантов (для admin GET /tenants).
func (s *TenantStore) List() []*Tenant {
s.mu.RLock()
+121 -12
View File
@@ -3,9 +3,9 @@
app/ui/index.html
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
Created: 2026-04-10
Updated: 2026-04-10 — queue CRUD (create/delete) + message peek/send/purge
Updated: 2026-04-10 — JWT auth через nubes token, email в navbar, auto-provisioning
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
Режим: Публичный UI API без авторизации (демо). Все данные in-memory.
Режим: JWT авторизация через nubes API. Пользователь вводит токен → валидация → сессия.
-->
<html lang="ru">
<head>
@@ -324,15 +324,33 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
</head>
<body>
<!-- ===== APP SHELL (всегда видим, без логина) ===== -->
<div id="app">
<!-- ===== LOGIN PAGE ===== -->
<div id="login-page" class="login-page">
<div class="login-box">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<h1>SQS CONSOLE</h1>
<div class="form-group">
<label for="login-token">API Token (nubes JWT)</label>
<input id="login-token" type="password" placeholder="eyJhbGciOiJSUzI1NiIs...">
</div>
<div id="login-error" class="login-error"></div>
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
Токен можно получить в панели управления облаком Nubes
</p>
</div>
</div>
<!-- ===== APP SHELL (показывается после авторизации) ===== -->
<div id="app" class="hidden">
<nav class="navbar">
<div class="navbar-brand">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<span>SQS CONSOLE</span>
</div>
<div class="navbar-user">
<span>admin</span>
<span id="user-email" style="color:var(--accent)"></span>
<button class="btn-logout" onclick="doLogout()">Выйти</button>
</div>
</nav>
<div class="container">
@@ -447,23 +465,104 @@ let msgCache = {}; // id → объект сообщения для d
let _sendState = { tenantId: null, queueName: null }; // контекст sendMessage
let _createQueueState = { tenantId: null }; // контекст createQueue
// Session state — JWT auth
let sessionToken = null; // JWT токен для Authorization header
let sessionEmail = null; // Email из JWT для отображения в UI
// ===== INIT =====
// Запуск — сразу показываем dashboard без логина
// Проверяем сохранённый токен в localStorage при загрузке
(function init() {
BASE = window.location.origin;
showDashboard();
const saved = localStorage.getItem('sqs_session');
if (saved) {
try {
const s = JSON.parse(saved);
if (s.token && s.email) {
sessionToken = s.token;
sessionEmail = s.email;
enterApp();
return;
}
} catch(e) { /* corrupted — show login */ }
}
showLogin();
})();
// ===== LOGIN / LOGOUT =====
// showLogin — показывает страницу ввода токена
function showLogin() {
document.getElementById('login-page').classList.remove('hidden');
document.getElementById('app').classList.add('hidden');
if (refreshTimer) clearInterval(refreshTimer);
}
// doLogin — отправляет токен на /ui/api/auth, при успехе входит в app
function doLogin() {
const token = document.getElementById('login-token').value.trim();
if (!token) {
document.getElementById('login-error').textContent = 'Введите токен';
return;
}
document.getElementById('login-error').textContent = 'Проверка токена...';
fetch(BASE + '/ui/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(r => {
if (!r.ok) return r.json().then(d => { throw new Error(d.error || r.statusText); });
return r.json();
})
.then(data => {
sessionToken = data.token;
sessionEmail = data.email || data.tenant_id;
localStorage.setItem('sqs_session', JSON.stringify({
token: sessionToken,
email: sessionEmail
}));
enterApp();
})
.catch(err => {
document.getElementById('login-error').textContent = 'Ошибка: ' + err.message;
});
}
// doLogout — очищает сессию, показывает login
function doLogout() {
sessionToken = null;
sessionEmail = null;
localStorage.removeItem('sqs_session');
if (refreshTimer) clearInterval(refreshTimer);
showLogin();
}
// enterApp — переключает UI с login на app shell
function enterApp() {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
document.getElementById('user-email').textContent = sessionEmail || '';
showDashboard();
}
// ===== API HELPER =====
// api — выполняет запрос к публичному UI API (без auth)
// api — выполняет запрос к UI API с JWT Authorization header
function api(path, opts = {}) {
const headers = {
'Content-Type': 'application/json',
...(opts.headers || {})
};
if (sessionToken) {
headers['Authorization'] = 'Bearer ' + sessionToken;
}
return fetch(BASE + '/ui/api' + path, {
...opts,
headers: {
'Content-Type': 'application/json',
...(opts.headers || {})
}
headers: headers
}).then(r => {
if (r.status === 401 || r.status === 403) {
// Токен истёк или невалиден — выбросить на login
doLogout();
throw new Error('Session expired — please login again');
}
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
if (r.status === 204) return null;
return r.json();
@@ -927,6 +1026,16 @@ function purgeQueueConfirm(tenantId, queueName) {
})
.catch(err => alert('Ошибка: ' + err.message));
}
// Клавиша Enter на поле токена = нажатие "Войти"
document.addEventListener('DOMContentLoaded', function() {
const tokenInput = document.getElementById('login-token');
if (tokenInput) {
tokenInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') doLogin();
});
}
});
</script>
</body>
</html>