v0.1.28: email-идентичность — детерминированные креды из email, credentials-эндпоинт, UI модалка
This commit is contained in:
+30
-6
@@ -155,6 +155,7 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
||||
// jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint
|
||||
ui.Use(h.jwtMiddleware)
|
||||
ui.HandleFunc("/auth", h.jwtAuth).Methods("POST")
|
||||
ui.HandleFunc("/credentials", h.uiCredentials).Methods("GET")
|
||||
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||
ui.HandleFunc("/tenants", h.listTenants).Methods("GET")
|
||||
ui.HandleFunc("/tenants", h.createTenant).Methods("POST")
|
||||
@@ -221,6 +222,14 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Email — единственный ключ идентичности. Без него не создаём тенанта
|
||||
// (иначе hash("") даст один тенант на всех пользователей без email).
|
||||
if claims.Email == "" {
|
||||
log.Warnf("jwt auth: token for sub=%s has no email claim", claims.Sub)
|
||||
jsonErr(w, http.StatusBadRequest, "token has no email claim")
|
||||
return
|
||||
}
|
||||
|
||||
// Валидируем через nubes API
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -230,9 +239,9 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-provisioning: создаём тенанта если не существует
|
||||
tenantID := auth.TenantIDFromSub(claims.Sub)
|
||||
t, err := h.store.CreateFromJWT(tenantID, claims.Sub, claims.Email, 10)
|
||||
// Auto-provisioning: тенант и ключи детерминированы из email.
|
||||
// Токен — только аутентификация; ключи в ответ не возвращаем (GET /ui/api/credentials).
|
||||
t, err := h.store.CreateFromJWT(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")
|
||||
@@ -245,13 +254,28 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
||||
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, // возвращаем для использования в последующих запросах
|
||||
})
|
||||
}
|
||||
|
||||
// uiCredentials — GET /ui/api/credentials: отдаёт AccessKey/SecretKey текущего тенанта.
|
||||
// Ключи НЕ светятся в других ответах UI API — только по явному запросу под JWT-сессией.
|
||||
func (h *Handler) uiCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := currentUITenant(r)
|
||||
if !ok {
|
||||
jsonErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"tenant_id": t.ID,
|
||||
"email": t.Email,
|
||||
"access_key": t.AccessKey,
|
||||
"secret_key": t.SecretKey,
|
||||
})
|
||||
}
|
||||
|
||||
// jwtMiddleware — middleware для /ui/api/* endpoints.
|
||||
// Пропускает /ui/api/auth (публичный endpoint авторизации).
|
||||
// Проверяет Authorization: Bearer <jwt> заголовок.
|
||||
@@ -306,7 +330,7 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Проверяем что тенант существует (был создан при /ui/api/auth)
|
||||
jwtTenant, ok := h.store.GetBySub(claims.Sub)
|
||||
jwtTenant, ok := h.store.GetByEmail(claims.Email)
|
||||
if !ok {
|
||||
jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth")
|
||||
return
|
||||
|
||||
@@ -7,7 +7,6 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -65,15 +64,6 @@ func ParseJWTClaims(token string) (*JWTClaims, error) {
|
||||
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])
|
||||
}
|
||||
|
||||
// nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен.
|
||||
// Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял.
|
||||
// Юзер может вводить токен от любого стенда — главное, что токен действительный.
|
||||
|
||||
+67
-44
@@ -4,9 +4,11 @@ package tenant
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -19,7 +21,7 @@ const MaxTenantsGlobal = 1000
|
||||
// Tenant — модель тенанта shared-sqs.
|
||||
// AccessKey используется как идентификатор в AWS Authorization header.
|
||||
type Tenant struct {
|
||||
ID string // уникальный идентификатор тенанта (sless-<hex> из JWT sub, или t-<hex> для legacy)
|
||||
ID string // уникальный идентификатор тенанта (t-<hex>: детерминированный из email для JWT-тенантов, случайный для manual)
|
||||
Name string // человекочитаемое имя
|
||||
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
||||
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
|
||||
@@ -31,12 +33,12 @@ type Tenant struct {
|
||||
}
|
||||
|
||||
// TenantStore — потокобезопасное in-memory хранилище тенантов.
|
||||
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth).
|
||||
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по Email (JWT auth).
|
||||
type TenantStore struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]*Tenant
|
||||
byAccessKey map[string]*Tenant
|
||||
bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim)
|
||||
byEmail map[string]*Tenant // индекс по Email (JWT claim). Тенанты без email сюда НЕ попадают.
|
||||
}
|
||||
|
||||
// NewTenantStore — создаёт пустое хранилище тенантов.
|
||||
@@ -44,7 +46,7 @@ func NewTenantStore() *TenantStore {
|
||||
return &TenantStore{
|
||||
byID: make(map[string]*Tenant),
|
||||
byAccessKey: make(map[string]*Tenant),
|
||||
bySub: make(map[string]*Tenant),
|
||||
byEmail: make(map[string]*Tenant),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +85,8 @@ 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
|
||||
if t.Email != "" {
|
||||
s.byEmail[t.Email] = t
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -156,8 +158,8 @@ 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)
|
||||
if t.Email != "" {
|
||||
delete(s.byEmail, t.Email)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -173,60 +175,61 @@ 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
|
||||
if t.Email != "" {
|
||||
s.byEmail[t.Email] = t
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetBySub — поиск тенанта по NubesSub (JWT sub claim).
|
||||
// GetByEmail — поиск тенанта по Email (JWT claim).
|
||||
// Используется при JWT-авторизации через UI.
|
||||
func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) {
|
||||
func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) {
|
||||
s.mu.RLock()
|
||||
t, ok := s.bySub[sub]
|
||||
t, ok := s.byEmail[email]
|
||||
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) {
|
||||
// Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email.
|
||||
// Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле.
|
||||
// Легаси-тенант (старые случайные ключи / другой ID) пересчитывается:
|
||||
// старые индексы и Redis-запись удаляются, создаётся детерминированная запись.
|
||||
// ВНИМАНИЕ: очереди привязаны к старому AccessKey — при пересчёте они остаются
|
||||
// в SyncQueues/Redis под старым префиксом (текущий тенант имеет 0 очередей).
|
||||
func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) {
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("JWT email is required for tenant provisioning")
|
||||
}
|
||||
tenantID := tenantIDFromEmail(email)
|
||||
accessKey := accessKeyFromEmail(email)
|
||||
secretKey := secretKeyFromEmail(email)
|
||||
|
||||
s.mu.Lock()
|
||||
// Идемпотентность: если тенант с таким sub уже есть — возвращаем
|
||||
if existing, ok := s.bySub[sub]; ok {
|
||||
// Обновляем email если изменился
|
||||
if email != "" && existing.Email != email {
|
||||
existing.Email = email
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Идемпотентность: тенант с таким email уже есть
|
||||
if existing, ok := s.byEmail[email]; ok {
|
||||
// Полное совпадение — возвращаем как есть
|
||||
if existing.ID == tenantID && existing.AccessKey == accessKey {
|
||||
if sub != "" {
|
||||
existing.NubesSub = sub
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return existing, nil
|
||||
// Легаси: убираем старую запись из всех индексов и из Redis, ниже создаём новую
|
||||
delete(s.byID, existing.ID)
|
||||
delete(s.byAccessKey, existing.AccessKey)
|
||||
delete(s.byEmail, existing.Email)
|
||||
persistence.DeleteTenant(existing.ID)
|
||||
}
|
||||
if len(s.byID) >= MaxTenantsGlobal {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal)
|
||||
}
|
||||
|
||||
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,
|
||||
Name: email,
|
||||
AccessKey: accessKey,
|
||||
SecretKey: secretKey,
|
||||
MaxQueues: maxQueues,
|
||||
@@ -238,8 +241,7 @@ func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int)
|
||||
|
||||
s.byID[t.ID] = t
|
||||
s.byAccessKey[t.AccessKey] = t
|
||||
s.bySub[t.NubesSub] = t
|
||||
s.mu.Unlock()
|
||||
s.byEmail[t.Email] = t
|
||||
|
||||
// Сохраняем в Redis
|
||||
if data, err := json.Marshal(t); err == nil {
|
||||
@@ -249,6 +251,27 @@ func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// tenantIDFromEmail — детерминированный ID тенанта из email: "t-{16 hex}".
|
||||
func tenantIDFromEmail(email string) string {
|
||||
hash := sha256.Sum256([]byte(strings.ToLower(email)))
|
||||
return "t-" + hex.EncodeToString(hash[:8])
|
||||
}
|
||||
|
||||
// accessKeyFromEmail — детерминированный AccessKey: "SSAK-{24 hex}".
|
||||
func accessKeyFromEmail(email string) string {
|
||||
hash := sha256.Sum256([]byte(strings.ToLower(email)))
|
||||
return "SSAK-" + hex.EncodeToString(hash[:12])
|
||||
}
|
||||
|
||||
// secretKeySalt — соль для детерминированного SecretKey email-тенантов.
|
||||
const secretKeySalt = "shared-sqs:secret-key:v1"
|
||||
|
||||
// secretKeyFromEmail — детерминированный SecretKey: 64 hex = SHA256(email + соль).
|
||||
func secretKeyFromEmail(email string) string {
|
||||
h := sha256.Sum256([]byte(strings.ToLower(email) + ":" + secretKeySalt))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// List — список всех тенантов (для admin GET /tenants).
|
||||
func (s *TenantStore) List() []*Tenant {
|
||||
s.mu.RLock()
|
||||
|
||||
+27
-11
@@ -347,7 +347,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
|
||||
<div>Admin API: <span style="font-family:monospace">POST /admin/tenants</span> (Bearer token)</div>
|
||||
<div>Realm: <span style="font-family:monospace">iot-naeel</span> · Persistence: Managed Redis</div>
|
||||
<div>Версия: <span id="svc-version" style="font-family:monospace">—</span></div>
|
||||
<div>Образ: <span style="font-family:monospace">naeel/shared-sqs:v0.1.27</span></div>
|
||||
<div>Образ: <span style="font-family:monospace">naeel/shared-sqs:v0.1.28</span></div>
|
||||
<div style="margin-top:8px;color:var(--warning)">Статус: тестирование</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,10 +396,18 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
|
||||
<!-- ===== CREDENTIALS MODAL ===== -->
|
||||
<div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()">
|
||||
<div class="modal">
|
||||
<h2>Тенант создан</h2>
|
||||
<p style="color:var(--warning);font-size:13px;margin-bottom:16px">
|
||||
⚠ Сохраните credentials — Secret Key показывается только один раз.
|
||||
<h2>Credentials</h2>
|
||||
<p style="color:var(--warning);font-size:13px;margin-bottom:12px">
|
||||
⚠ Сохраните ключи — это креды вашего тенанта для AWS CLI/SDK.
|
||||
</p>
|
||||
<div style="font-size:12px;color:var(--text-secondary);background:var(--bg-page);border:1px solid var(--border);border-radius:4px;padding:10px 12px;margin-bottom:16px;line-height:1.8;word-break:break-all">
|
||||
<strong>Зачем:</strong> приложения подключаются к SQS API по протоколу AWS
|
||||
(подпись Signature V4). Токен входа в консоль для этого не подходит.<br>
|
||||
<strong>Что делать:</strong><br>
|
||||
<code>export AWS_ACCESS_KEY_ID=<Access Key></code><br>
|
||||
<code>export AWS_SECRET_ACCESS_KEY=<Secret Key></code><br>
|
||||
<code>aws sqs list-queues --endpoint-url https://sqs.containerk8s.dev.nubes.ru --region us-east-1</code>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Access Key</label>
|
||||
<input id="creds-ak" readonly onclick="copyField(this)">
|
||||
@@ -647,7 +655,6 @@ function renderDashboard(health, tenants) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
<th>Access Key</th>
|
||||
<th>Макс. очередей</th>
|
||||
<th>Статус</th>
|
||||
<th>Создан</th>
|
||||
@@ -657,7 +664,6 @@ function renderDashboard(health, tenants) {
|
||||
${(tenants || []).map(t => `
|
||||
<tr onclick="showTenant('${esc(t.id)}')">
|
||||
<td><strong>${esc(t.name)}</strong></td>
|
||||
<td style="font-family:monospace;font-size:12px">${esc(t.access_key)}</td>
|
||||
<td>${t.max_queues}</td>
|
||||
<td>${t.active
|
||||
? '<span class="badge badge-active">active</span>'
|
||||
@@ -665,7 +671,7 @@ function renderDashboard(health, tenants) {
|
||||
<td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
${(!tenants || tenants.length === 0) ? '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:32px">Tenant не найден</td></tr>' : ''}
|
||||
${(!tenants || tenants.length === 0) ? '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:32px">Tenant не найден</td></tr>' : ''}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -717,16 +723,13 @@ function renderTenant(tenant, queues) {
|
||||
<div class="stat-value">${tenant.max_queues}</div>
|
||||
<div class="stat-label">Лимит очередей</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size:20px;font-family:monospace">${esc(tenant.access_key)}</div>
|
||||
<div class="stat-label">Access Key</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<h2>Очереди</h2>
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<div class="auto-refresh"><span>⟳ 10с</span></div>
|
||||
<button class="btn btn-primary btn-sm" onclick="showCredentials()">Credentials</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="openCreateQueueModal('${esc(tenant.id)}')" >+ Очередь</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -817,6 +820,19 @@ function closeCredsModal() {
|
||||
document.getElementById('modal-creds').classList.add('hidden');
|
||||
}
|
||||
|
||||
// showCredentials — запрашивает ключи тенанта и показывает их в модалке
|
||||
function showCredentials() {
|
||||
api('/credentials')
|
||||
.then(data => {
|
||||
document.getElementById('creds-ak').value = data.access_key || '';
|
||||
document.getElementById('creds-sk').value = data.secret_key || '';
|
||||
document.getElementById('modal-creds').classList.remove('hidden');
|
||||
})
|
||||
.catch(err => {
|
||||
alert('Не удалось получить credentials: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// deleteTenant — DELETE /tenants/{id} с подтверждением
|
||||
function deleteTenant(id, name) {
|
||||
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return;
|
||||
|
||||
Reference in New Issue
Block a user