P0-1/P0-2: tenant-граница по sub + обязательная подпись JWT
validateJWT теперь возвращает sub; middleware сверяет sub с {namespace} из URL
(несовпадение → 403) — закрывает горизонтальный доступ к чужим namespace.
Вне AUTH_TEST_MODE подпись HS256 обязательна: без JWT_HMAC_SECRET middleware
отказывает (fail-closed, 500), а config.Load требует секрет на старте.
This commit is contained in:
@@ -8,6 +8,8 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Auth — проверка Bearer-токена.
|
// Auth — проверка Bearer-токена.
|
||||||
@@ -39,11 +41,29 @@ func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Hand
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateJWT(token, hmacSecret); err != nil {
|
// Fail-closed: вне тестового режима подпись ОБЯЗАТЕЛЬНА. Без секрета
|
||||||
|
// токен можно подделать — отказываем в обслуживании, а не проверяем «структурно».
|
||||||
|
if !authTestMode && hmacSecret == "" {
|
||||||
|
log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)")
|
||||||
|
http.Error(w, `{"error":"auth misconfigured"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := validateJWT(token, hmacSecret)
|
||||||
|
if 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tenant-граница: `sub` из токена ОБЯЗАН совпадать с {namespace} из URL.
|
||||||
|
// Иначе любой валидный токен читал/писал бы любой namespace (горизонтальный доступ).
|
||||||
|
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)
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -62,11 +82,11 @@ type jwtError struct{ msg string }
|
|||||||
func (e *jwtError) Error() string { return e.msg }
|
func (e *jwtError) Error() string { return e.msg }
|
||||||
|
|
||||||
// validateJWT проверяет структуру JWT (sub + exp) и, если задан secret,
|
// validateJWT проверяет структуру JWT (sub + exp) и, если задан secret,
|
||||||
// HS256-подпись. Без secret подпись не проверяется — см. комментарий Auth.
|
// HS256-подпись. Возвращает `sub` (идентичность пользователя) для сверки с namespace.
|
||||||
func validateJWT(token, secret string) error {
|
func validateJWT(token, secret string) (string, error) {
|
||||||
jwtParts := strings.Split(token, ".")
|
jwtParts := strings.Split(token, ".")
|
||||||
if len(jwtParts) != 3 {
|
if len(jwtParts) != 3 {
|
||||||
return &jwtError{"not a JWT: expected 3 parts"}
|
return "", &jwtError{"not a JWT: expected 3 parts"}
|
||||||
}
|
}
|
||||||
|
|
||||||
if secret != "" {
|
if secret != "" {
|
||||||
@@ -78,17 +98,17 @@ func validateJWT(token, secret string) error {
|
|||||||
headerBytes, err = base64.StdEncoding.DecodeString(jwtParts[0])
|
headerBytes, err = base64.StdEncoding.DecodeString(jwtParts[0])
|
||||||
}
|
}
|
||||||
if err != nil || jsonUnmarshal(headerBytes, &header) != nil || header.Alg != "HS256" {
|
if err != nil || jsonUnmarshal(headerBytes, &header) != nil || header.Alg != "HS256" {
|
||||||
return &jwtError{"JWT must be HS256 when JWT_HMAC_SECRET is set"}
|
return "", &jwtError{"JWT must be HS256 when JWT_HMAC_SECRET is set"}
|
||||||
}
|
}
|
||||||
mac := hmac.New(sha256.New, []byte(secret))
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
mac.Write([]byte(jwtParts[0] + "." + jwtParts[1]))
|
mac.Write([]byte(jwtParts[0] + "." + jwtParts[1]))
|
||||||
expected := mac.Sum(nil)
|
expected := mac.Sum(nil)
|
||||||
sig, err := base64.RawURLEncoding.DecodeString(jwtParts[2])
|
sig, err := base64.RawURLEncoding.DecodeString(jwtParts[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &jwtError{"cannot decode JWT signature"}
|
return "", &jwtError{"cannot decode JWT signature"}
|
||||||
}
|
}
|
||||||
if !hmac.Equal(expected, sig) {
|
if !hmac.Equal(expected, sig) {
|
||||||
return &jwtError{"JWT signature mismatch"}
|
return "", &jwtError{"JWT signature mismatch"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +124,7 @@ func validateJWT(token, secret string) error {
|
|||||||
decoded, err = base64.StdEncoding.DecodeString(payload)
|
decoded, err = base64.StdEncoding.DecodeString(payload)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &jwtError{"cannot decode JWT payload"}
|
return "", &jwtError{"cannot decode JWT payload"}
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims struct {
|
var claims struct {
|
||||||
@@ -112,15 +132,15 @@ func validateJWT(token, secret string) error {
|
|||||||
Exp float64 `json:"exp"`
|
Exp float64 `json:"exp"`
|
||||||
}
|
}
|
||||||
if err := jsonUnmarshal(decoded, &claims); err != nil {
|
if err := jsonUnmarshal(decoded, &claims); err != nil {
|
||||||
return &jwtError{"cannot parse JWT payload"}
|
return "", &jwtError{"cannot parse JWT payload"}
|
||||||
}
|
}
|
||||||
if claims.Sub == "" {
|
if claims.Sub == "" {
|
||||||
return &jwtError{"JWT missing sub claim"}
|
return "", &jwtError{"JWT missing sub claim"}
|
||||||
}
|
}
|
||||||
if claims.Exp > 0 {
|
if claims.Exp > 0 {
|
||||||
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 nil
|
return claims.Sub, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ func Load() (*Config, error) {
|
|||||||
if cfg.MQTTPassword == "" {
|
if cfg.MQTTPassword == "" {
|
||||||
missing = append(missing, "MQTT_PASSWORD")
|
missing = append(missing, "MQTT_PASSWORD")
|
||||||
}
|
}
|
||||||
|
// Вне тестового режима JWT_HMAC_SECRET ОБЯЗАТЕЛЕН: без него подпись не
|
||||||
|
// проверяется и токен можно подделать (fail-closed на старте).
|
||||||
|
if !cfg.AuthTestMode && cfg.JwtHMACSecret == "" {
|
||||||
|
missing = append(missing, "JWT_HMAC_SECRET (или включите AUTH_TEST_MODE для тестов)")
|
||||||
|
}
|
||||||
if len(missing) > 0 {
|
if len(missing) > 0 {
|
||||||
return nil, fmt.Errorf("required env vars not set: %s", strings.Join(missing, ", "))
|
return nil, fmt.Errorf("required env vars not set: %s", strings.Join(missing, ", "))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user