From 3bf1dd604cc62f26e8b6acff88e56af68e31d5f6 Mon Sep 17 00:00:00 2001 From: Naeel Date: Sun, 5 Apr 2026 10:46:29 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20test=20auth=20mode=20=E2=80=94=20accept?= =?UTF-8?q?=20any=20plain=20token=20without=20JWT=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authTestMode=true in middleware/auth.go: - any non-whitespace, non-JWT string is accepted as Bearer token - string is used as sub for namespace derivation (SHA256) - JWT validation still runs for actual JWT strings (xxx.yyy.zzz) - revert to strict mode: authTestMode = false iot-console.html: - namespaceFromToken: plain tokens use the string itself as sub - login form: updated placeholder + hint explaining test mode Image: v0.1.60 --- internal/api/middleware/auth.go | 58 +++++++++++++++++++++++++++++--- internal/api/ui/iot-console.html | 54 +++++++++++++++++++---------- 2 files changed, 91 insertions(+), 21 deletions(-) diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 3573da9..a79e7eb 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-11 +// Изменено: 2026-04-05 // Auth middleware — проверяет Bearer JWT-токен из заголовка Authorization. // // Архитектура аутентификации: @@ -10,6 +10,17 @@ // внешний доступ — через Ingress, где токен уже проверен на уровне API-шлюза. // // TODO v2: получать публичный ключ из nubes JWKS endpoint и проверять подпись RS256. +// +// ────────────────────────────────────────────────────────────────────────────── +// ТЕСТОВЫЙ РЕЖИМ (authTestMode = true): +// Принимается ЛЮБАЯ строка без пробелов — не обязательно JWT. +// Это позволяет тестировать UI/API без реального токена nubes. +// Строка используется как идентификатор пользователя (аналог JWT.sub), +// namespace выводится из неё так же: SHA256 → первые 16 байт hex → "sless-{hex}". +// +// ⚠️ ПЕРЕД ВЫХОДОМ В ПРОД: установить authTestMode = false. +// Для возврата к строгой JWT-валидации: одна строка ниже. +// ────────────────────────────────────────────────────────────────────────────── package middleware @@ -22,11 +33,25 @@ import ( "time" ) +// authTestMode — ТЕСТОВЫЙ РЕЖИМ аутентификации. +// +// true → принимается любая строка без пробелов (не обязательно JWT). +// +// Используется при разработке UI когда реальный токен nubes не нужен. +// +// false → строгая проверка JWT (структура + sub + exp). +// +// Необходимо установить перед деплоем в прод. +// +// Чтобы вернуться к JWT: изменить на false. +const authTestMode = true + // Auth возвращает middleware которое требует заголовок: // -// Authorization: Bearer +// Authorization: Bearer // -// Проверяет: структура JWT (3 части), наличие "sub", отсутствие истечения "exp". +// В тестовом режиме (authTestMode=true): принимает любую строку без пробелов. +// В боевом режиме (authTestMode=false): требует валидный JWT (sub + exp). func Auth(log *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { header := r.Header.Get("Authorization") @@ -41,7 +66,20 @@ func Auth(log *slog.Logger, next http.Handler) http.Handler { http.Error(w, `{"error":"invalid authorization format, use Bearer "}`, http.StatusUnauthorized) return } - if err := validateJWT(parts[1]); err != nil { + token := parts[1] + + // ── ТЕСТОВЫЙ РЕЖИМ ─────────────────────────────────────────────────── + // Если authTestMode=true и токен — просто строка без пробелов (не JWT), + // пропускаем JWT-валидацию. Строка обрабатывается как произвольный sub. + // Чтобы вернуть строгую проверку: установить authTestMode = false. + if authTestMode && isPlainToken(token) { + log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path) + next.ServeHTTP(w, r) + return + } + // ── БОЕВОЙ РЕЖИМ / JWT ─────────────────────────────────────────────── + + if err := validateJWT(token); err != nil { log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error()) http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden) return @@ -50,6 +88,18 @@ func Auth(log *slog.Logger, next http.Handler) http.Handler { }) } +// isPlainToken возвращает true если токен — непустая строка без пробелов и НЕ является JWT. +// JWT определяется по наличию ровно двух точек (xxx.yyy.zzz). +// Логика: если строка выглядит как JWT — проверять через validateJWT (даже в testMode). +func isPlainToken(token string) bool { + if token == "" || strings.ContainsAny(token, " \t\n\r") { + return false + } + // Если три части через точку — скорее всего JWT, проверять нормально + parts := strings.Split(token, ".") + return len(parts) != 3 +} + // validateJWT проверяет структуру JWT и claim "sub" и "exp". // Подпись НЕ проверяется — см. комментарий к файлу. func validateJWT(token string) error { diff --git a/internal/api/ui/iot-console.html b/internal/api/ui/iot-console.html index 6f91e35..faa4635 100644 --- a/internal/api/ui/iot-console.html +++ b/internal/api/ui/iot-console.html @@ -285,26 +285,42 @@ function saveSettings() { localStorage.setItem('iot_mqtt_base',S.mqttBase); } -// namespaceFromToken — вычисляет k8s namespace из JWT токена. +// namespaceFromToken — вычисляет k8s namespace из токена. // Алгоритм ИДЕНТИЧЕН Terraform провайдеру (client.NamespaceFromSub): -// JWT payload → sub claim → SHA256(sub) → первые 8 байт → "sless-{16 hex символов}" -// Пользователь НИКОГДА не вводит namespace вручную — это гарантирует изоляцию. +// SHA256(sub) → первые 8 байт → "sless-{16 hex символов}" +// +// Поддерживает два режима (соответствует authTestMode на сервере): +// - Режим JWT (прод): токен вида xxx.yyy.zzz → извлекаем sub из payload → SHA256(sub) +// - Тестовый режим: любая строка без пробелов → используем её саму как sub → SHA256(token) +// +// Чтобы вернуться только к JWT: убрать ветку isPlainToken ниже. async function namespaceFromToken(token) { + let sub; + const parts = token.split('.'); - if (parts.length !== 3) throw new Error('Неверный формат токена (не JWT)'); + if (parts.length === 3) { + // ── JWT путь (боевой режим) ────────────────────────────────────────────── + // base64url → base64 (заменяем символы, добавляем padding) + let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + while (payload.length % 4) payload += '='; - // base64url → base64 (заменяем символы, добавляем padding) - let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - while (payload.length % 4) payload += '='; + let claims; + try { claims = JSON.parse(atob(payload)); } + catch(e) { throw new Error('Не удалось декодировать токен: ' + e.message); } - let claims; - try { claims = JSON.parse(atob(payload)); } - catch(e) { throw new Error('Не удалось декодировать токен: ' + e.message); } + if (!claims.sub) throw new Error('В токене нет поля sub — токен не поддерживается'); + sub = claims.sub; + } else { + // ── Тестовый режим: plain token ───────────────────────────────────────── + // Строка без пробелов используется напрямую как идентификатор (sub). + // Сервер (authTestMode=true) тоже принимает такой токен без JWT-проверки. + // ВАЖНО: каждая уникальная строка → отдельный изолированный namespace. + if (/\s/.test(token)) throw new Error('Токен не должен содержать пробелы'); + sub = token; + } - if (!claims.sub) throw new Error('В токене нет поля sub — токен не поддерживается'); - - // SHA256(sub) через Web Crypto API (встроен в все современные браузеры) - const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(claims.sub)); + // SHA256(sub) через Web Crypto API (встроен во все современные браузеры) + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(sub)); // Берём первые 8 байт → 16 hex символов — идентично Go: hash[:8] const hex = Array.from(new Uint8Array(buf).slice(0, 8)) @@ -541,9 +557,13 @@ function renderLogin() {
- - -
Данные вашего аккаунта определяются автоматически
+ + +
+ Тест: введите любую строку без пробелов — например alice или my-device-test.
+ Каждая строка → отдельный изолированный namespace.
+ Реальный JWT nubes тоже принимается без изменений. +