fix(iot): JWT — обязательный exp, 401 вместо 403, guard тест-режима, JSON-ошибки (Этап 4)
- validateJWT: exp теперь ОБЯЗАТЕЛЕН (без него токен был вечным). - middleware: невалидный/просроченный токен → 401 (было 403) — клиент/Terraform отличают «протух» от «чужой namespace» (там осталось 403). - AUTH_TEST_MODE игнорируется при заданном JWT_HMAC_SECRET (иначе обходил подпись). - Все ошибки middleware — application/json через authErr (были text/plain).
This commit is contained in:
@@ -19,23 +19,27 @@ import (
|
|||||||
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
|
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
|
||||||
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
|
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
|
||||||
// структурная проверка (периметр обеспечивает платформа).
|
// структурная проверка (периметр обеспечивает платформа).
|
||||||
|
//
|
||||||
|
// Тест-режим игнорируется при заданном hmacSecret (иначе обходил бы подпись).
|
||||||
func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Handler) http.Handler {
|
func Auth(authTestMode bool, hmacSecret string, 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")
|
||||||
if header == "" {
|
if header == "" {
|
||||||
log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path)
|
log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path)
|
||||||
http.Error(w, `{"error":"authorization required"}`, http.StatusUnauthorized)
|
authErr(w, http.StatusUnauthorized, "authorization required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
parts := strings.SplitN(header, " ", 2)
|
parts := strings.SplitN(header, " ", 2)
|
||||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||||
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
|
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
|
||||||
http.Error(w, `{"error":"invalid authorization format, use Bearer <token>"}`, http.StatusUnauthorized)
|
authErr(w, http.StatusUnauthorized, "invalid authorization format, use Bearer <token>")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := parts[1]
|
token := parts[1]
|
||||||
|
|
||||||
if authTestMode && isPlainToken(token) {
|
// Тест-режим только без секрета: при заданном JWT_HMAC_SECRET он обходил
|
||||||
|
// бы проверку подписи (ревью 2026-10-10).
|
||||||
|
if authTestMode && hmacSecret == "" && isPlainToken(token) {
|
||||||
log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
|
log.Info("auth: test mode — plain token accepted", "remote", r.RemoteAddr, "path", r.URL.Path)
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
@@ -45,14 +49,16 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
|
|||||||
// токен можно подделать — отказываем в обслуживании, а не проверяем «структурно».
|
// токен можно подделать — отказываем в обслуживании, а не проверяем «структурно».
|
||||||
if !authTestMode && hmacSecret == "" {
|
if !authTestMode && hmacSecret == "" {
|
||||||
log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)")
|
log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)")
|
||||||
http.Error(w, `{"error":"auth misconfigured"}`, http.StatusInternalServerError)
|
authErr(w, http.StatusInternalServerError, "auth misconfigured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sub, err := validateJWT(token, hmacSecret)
|
sub, err := validateJWT(token, hmacSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 401, а не 403: невалидный/просроченный токен — не аутентифицирован
|
||||||
|
// (клиент/Terraform должны отличать «протух» от «чужой namespace»).
|
||||||
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)
|
authErr(w, http.StatusUnauthorized, "invalid token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +66,7 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
|
|||||||
// Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ).
|
// Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ).
|
||||||
if ns := mux.Vars(r)["namespace"]; ns != "" && ns != sub {
|
if ns := mux.Vars(r)["namespace"]; ns != "" && ns != sub {
|
||||||
log.Warn("auth: namespace mismatch — forbidden", "remote", r.RemoteAddr, "sub", sub, "namespace", ns, "path", r.URL.Path)
|
log.Warn("auth: namespace mismatch — forbidden", "remote", r.RemoteAddr, "sub", sub, "namespace", ns, "path", r.URL.Path)
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
authErr(w, http.StatusForbidden, "forbidden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +74,14 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authErr — единый JSON-ответ об ошибке аутентификации (middleware раньше отдавал
|
||||||
|
// text/plain через http.Error, а хендлеры — JSON; ревью 2026-10-10 — единый контракт).
|
||||||
|
func authErr(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||||
|
}
|
||||||
|
|
||||||
// isPlainToken — непустая строка без пробелов, не похожая на JWT (три части через точку).
|
// isPlainToken — непустая строка без пробелов, не похожая на JWT (три части через точку).
|
||||||
func isPlainToken(token string) bool {
|
func isPlainToken(token string) bool {
|
||||||
if token == "" || strings.ContainsAny(token, " \t\n\r") {
|
if token == "" || strings.ContainsAny(token, " \t\n\r") {
|
||||||
@@ -137,10 +151,13 @@ func validateJWT(token, secret string) (string, error) {
|
|||||||
if claims.Sub == "" {
|
if claims.Sub == "" {
|
||||||
return "", &jwtError{"JWT missing sub claim"}
|
return "", &jwtError{"JWT missing sub claim"}
|
||||||
}
|
}
|
||||||
if claims.Exp > 0 {
|
// exp ОБЯЗАТЕЛЕН: без него токен вечный. Раньше проверка шла только при
|
||||||
|
// claims.Exp > 0, т.е. отсутствие exp = бессрочный токен (ревью 2026-10-10).
|
||||||
|
if claims.Exp == 0 {
|
||||||
|
return "", &jwtError{"JWT missing exp claim"}
|
||||||
|
}
|
||||||
if now := float64(timeNow().Unix()); claims.Exp < now {
|
if now := float64(timeNow().Unix()); claims.Exp < now {
|
||||||
return "", &jwtError{"JWT expired"}
|
return "", &jwtError{"JWT expired"}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return claims.Sub, nil
|
return claims.Sub, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user