- validateJWT: exp теперь ОБЯЗАТЕЛЕН (без него токен был вечным). - middleware: невалидный/просроченный токен → 401 (было 403) — клиент/Terraform отличают «протух» от «чужой namespace» (там осталось 403). - AUTH_TEST_MODE игнорируется при заданном JWT_HMAC_SECRET (иначе обходил подпись). - Все ошибки middleware — application/json через authErr (были text/plain).
164 lines
6.4 KiB
Go
164 lines
6.4 KiB
Go
// Package middleware — HTTP-промежуточные слои API.
|
||
package middleware
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/gorilla/mux"
|
||
)
|
||
|
||
// Auth — проверка Bearer-токена.
|
||
//
|
||
// authTestMode (env AUTH_TEST_MODE, дефолт false):
|
||
// - true — принимается любая строка без пробелов (для локальных тестов);
|
||
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
|
||
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
|
||
// структурная проверка (периметр обеспечивает платформа).
|
||
//
|
||
// Тест-режим игнорируется при заданном hmacSecret (иначе обходил бы подпись).
|
||
func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
header := r.Header.Get("Authorization")
|
||
if header == "" {
|
||
log.Warn("auth: missing authorization header", "remote", r.RemoteAddr, "path", r.URL.Path)
|
||
authErr(w, http.StatusUnauthorized, "authorization required")
|
||
return
|
||
}
|
||
parts := strings.SplitN(header, " ", 2)
|
||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||
log.Warn("auth: invalid authorization format", "remote", r.RemoteAddr)
|
||
authErr(w, http.StatusUnauthorized, "invalid authorization format, use Bearer <token>")
|
||
return
|
||
}
|
||
token := parts[1]
|
||
|
||
// Тест-режим только без секрета: при заданном 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)
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
// Fail-closed: вне тестового режима подпись ОБЯЗАТЕЛЬНА. Без секрета
|
||
// токен можно подделать — отказываем в обслуживании, а не проверяем «структурно».
|
||
if !authTestMode && hmacSecret == "" {
|
||
log.Error("auth: JWT_HMAC_SECRET не задан при AUTH_TEST_MODE=false — отказ (fail-closed)")
|
||
authErr(w, http.StatusInternalServerError, "auth misconfigured")
|
||
return
|
||
}
|
||
|
||
sub, err := validateJWT(token, hmacSecret)
|
||
if err != nil {
|
||
// 401, а не 403: невалидный/просроченный токен — не аутентифицирован
|
||
// (клиент/Terraform должны отличать «протух» от «чужой namespace»).
|
||
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
|
||
authErr(w, http.StatusUnauthorized, "invalid token")
|
||
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)
|
||
authErr(w, http.StatusForbidden, "forbidden")
|
||
return
|
||
}
|
||
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// 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 (три части через точку).
|
||
func isPlainToken(token string) bool {
|
||
if token == "" || strings.ContainsAny(token, " \t\n\r") {
|
||
return false
|
||
}
|
||
return len(strings.Split(token, ".")) != 3
|
||
}
|
||
|
||
// jwtError — структурная ошибка JWT.
|
||
type jwtError struct{ msg string }
|
||
|
||
func (e *jwtError) Error() string { return e.msg }
|
||
|
||
// validateJWT проверяет структуру JWT (sub + exp) и, если задан secret,
|
||
// HS256-подпись. Возвращает `sub` (идентичность пользователя) для сверки с namespace.
|
||
func validateJWT(token, secret string) (string, error) {
|
||
jwtParts := strings.Split(token, ".")
|
||
if len(jwtParts) != 3 {
|
||
return "", &jwtError{"not a JWT: expected 3 parts"}
|
||
}
|
||
|
||
if secret != "" {
|
||
var header struct {
|
||
Alg string `json:"alg"`
|
||
}
|
||
headerBytes, err := base64.RawURLEncoding.DecodeString(jwtParts[0])
|
||
if err != nil {
|
||
headerBytes, err = base64.StdEncoding.DecodeString(jwtParts[0])
|
||
}
|
||
if err != nil || jsonUnmarshal(headerBytes, &header) != nil || header.Alg != "HS256" {
|
||
return "", &jwtError{"JWT must be HS256 when JWT_HMAC_SECRET is set"}
|
||
}
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
mac.Write([]byte(jwtParts[0] + "." + jwtParts[1]))
|
||
expected := mac.Sum(nil)
|
||
sig, err := base64.RawURLEncoding.DecodeString(jwtParts[2])
|
||
if err != nil {
|
||
return "", &jwtError{"cannot decode JWT signature"}
|
||
}
|
||
if !hmac.Equal(expected, sig) {
|
||
return "", &jwtError{"JWT signature mismatch"}
|
||
}
|
||
}
|
||
|
||
payload := jwtParts[1]
|
||
switch len(payload) % 4 {
|
||
case 2:
|
||
payload += "=="
|
||
case 3:
|
||
payload += "="
|
||
}
|
||
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||
if err != nil {
|
||
decoded, err = base64.StdEncoding.DecodeString(payload)
|
||
}
|
||
if err != nil {
|
||
return "", &jwtError{"cannot decode JWT payload"}
|
||
}
|
||
|
||
var claims struct {
|
||
Sub string `json:"sub"`
|
||
Exp float64 `json:"exp"`
|
||
}
|
||
if err := jsonUnmarshal(decoded, &claims); err != nil {
|
||
return "", &jwtError{"cannot parse JWT payload"}
|
||
}
|
||
if claims.Sub == "" {
|
||
return "", &jwtError{"JWT missing sub claim"}
|
||
}
|
||
// 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 {
|
||
return "", &jwtError{"JWT expired"}
|
||
}
|
||
return claims.Sub, nil
|
||
}
|