feat: test auth mode — accept any plain token without JWT validation
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
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-03-11
|
// Изменено: 2026-04-05
|
||||||
// Auth middleware — проверяет Bearer JWT-токен из заголовка Authorization.
|
// Auth middleware — проверяет Bearer JWT-токен из заголовка Authorization.
|
||||||
//
|
//
|
||||||
// Архитектура аутентификации:
|
// Архитектура аутентификации:
|
||||||
@@ -10,6 +10,17 @@
|
|||||||
// внешний доступ — через Ingress, где токен уже проверен на уровне API-шлюза.
|
// внешний доступ — через Ingress, где токен уже проверен на уровне API-шлюза.
|
||||||
//
|
//
|
||||||
// TODO v2: получать публичный ключ из nubes JWKS endpoint и проверять подпись RS256.
|
// 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
|
package middleware
|
||||||
|
|
||||||
@@ -22,11 +33,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// authTestMode — ТЕСТОВЫЙ РЕЖИМ аутентификации.
|
||||||
|
//
|
||||||
|
// true → принимается любая строка без пробелов (не обязательно JWT).
|
||||||
|
//
|
||||||
|
// Используется при разработке UI когда реальный токен nubes не нужен.
|
||||||
|
//
|
||||||
|
// false → строгая проверка JWT (структура + sub + exp).
|
||||||
|
//
|
||||||
|
// Необходимо установить перед деплоем в прод.
|
||||||
|
//
|
||||||
|
// Чтобы вернуться к JWT: изменить на false.
|
||||||
|
const authTestMode = true
|
||||||
|
|
||||||
// Auth возвращает middleware которое требует заголовок:
|
// Auth возвращает middleware которое требует заголовок:
|
||||||
//
|
//
|
||||||
// Authorization: Bearer <jwt>
|
// Authorization: Bearer <token>
|
||||||
//
|
//
|
||||||
// Проверяет: структура JWT (3 части), наличие "sub", отсутствие истечения "exp".
|
// В тестовом режиме (authTestMode=true): принимает любую строку без пробелов.
|
||||||
|
// В боевом режиме (authTestMode=false): требует валидный JWT (sub + exp).
|
||||||
func Auth(log *slog.Logger, next http.Handler) http.Handler {
|
func Auth(log *slog.Logger, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
header := r.Header.Get("Authorization")
|
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 <token>"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"invalid authorization format, use Bearer <token>"}`, http.StatusUnauthorized)
|
||||||
return
|
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())
|
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
|
||||||
http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden)
|
http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden)
|
||||||
return
|
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".
|
// validateJWT проверяет структуру JWT и claim "sub" и "exp".
|
||||||
// Подпись НЕ проверяется — см. комментарий к файлу.
|
// Подпись НЕ проверяется — см. комментарий к файлу.
|
||||||
func validateJWT(token string) error {
|
func validateJWT(token string) error {
|
||||||
|
|||||||
@@ -285,26 +285,42 @@ function saveSettings() {
|
|||||||
localStorage.setItem('iot_mqtt_base',S.mqttBase);
|
localStorage.setItem('iot_mqtt_base',S.mqttBase);
|
||||||
}
|
}
|
||||||
|
|
||||||
// namespaceFromToken — вычисляет k8s namespace из JWT токена.
|
// namespaceFromToken — вычисляет k8s namespace из токена.
|
||||||
// Алгоритм ИДЕНТИЧЕН Terraform провайдеру (client.NamespaceFromSub):
|
// Алгоритм ИДЕНТИЧЕН Terraform провайдеру (client.NamespaceFromSub):
|
||||||
// JWT payload → sub claim → SHA256(sub) → первые 8 байт → "sless-{16 hex символов}"
|
// SHA256(sub) → первые 8 байт → "sless-{16 hex символов}"
|
||||||
// Пользователь НИКОГДА не вводит namespace вручную — это гарантирует изоляцию.
|
//
|
||||||
|
// Поддерживает два режима (соответствует authTestMode на сервере):
|
||||||
|
// - Режим JWT (прод): токен вида xxx.yyy.zzz → извлекаем sub из payload → SHA256(sub)
|
||||||
|
// - Тестовый режим: любая строка без пробелов → используем её саму как sub → SHA256(token)
|
||||||
|
//
|
||||||
|
// Чтобы вернуться только к JWT: убрать ветку isPlainToken ниже.
|
||||||
async function namespaceFromToken(token) {
|
async function namespaceFromToken(token) {
|
||||||
|
let sub;
|
||||||
|
|
||||||
const parts = token.split('.');
|
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 claims;
|
||||||
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
try { claims = JSON.parse(atob(payload)); }
|
||||||
while (payload.length % 4) payload += '=';
|
catch(e) { throw new Error('Не удалось декодировать токен: ' + e.message); }
|
||||||
|
|
||||||
let claims;
|
if (!claims.sub) throw new Error('В токене нет поля sub — токен не поддерживается');
|
||||||
try { claims = JSON.parse(atob(payload)); }
|
sub = claims.sub;
|
||||||
catch(e) { throw new Error('Не удалось декодировать токен: ' + e.message); }
|
} 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(sub));
|
||||||
// SHA256(sub) через Web Crypto API (встроен в все современные браузеры)
|
|
||||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(claims.sub));
|
|
||||||
|
|
||||||
// Берём первые 8 байт → 16 hex символов — идентично Go: hash[:8]
|
// Берём первые 8 байт → 16 hex символов — идентично Go: hash[:8]
|
||||||
const hex = Array.from(new Uint8Array(buf).slice(0, 8))
|
const hex = Array.from(new Uint8Array(buf).slice(0, 8))
|
||||||
@@ -541,9 +557,13 @@ function renderLogin() {
|
|||||||
</div>
|
</div>
|
||||||
<div id="login-err"></div>
|
<div id="login-err"></div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Bearer токен</label>
|
<label>Bearer токен <span style="color:#e08020;font-size:10px;font-weight:600;">· тестовый режим</span></label>
|
||||||
<input id="f-tok" type="password" value="${h(S.token)}" placeholder="eyJ...">
|
<input id="f-tok" type="password" value="${h(S.token)}" placeholder="любая-уникальная-строка или eyJ...JWT">
|
||||||
<div class="hint" style="margin-top:6px;">Данные вашего аккаунта определяются автоматически</div>
|
<div class="hint" style="margin-top:6px;">
|
||||||
|
<b>Тест:</b> введите любую строку без пробелов — например <code>alice</code> или <code>my-device-test</code>.<br>
|
||||||
|
Каждая строка → отдельный изолированный namespace.<br>
|
||||||
|
Реальный JWT nubes тоже принимается без изменений.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary mt8" style="width:100%;" id="login-btn" onclick="doLogin()">
|
<button class="btn btn-primary mt8" style="width:100%;" id="login-btn" onclick="doLogin()">
|
||||||
Войти
|
Войти
|
||||||
|
|||||||
Reference in New Issue
Block a user