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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user