Files
SQS-service/app/auth/auth_middleware.go
T

440 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Изменено: 2026-04-09
// Auth middleware для shared-sqs: извлекает AccessKeyId из AWS Authorization header
// и помещает найденного тенанта в context запроса.
package auth
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"shared-sqs/app/tenant"
)
// TenantContextKey — ключ для хранения тенанта в request context.
// Тип contextKey предотвращает конфликты с другими пакетами.
type contextKey string
const TenantContextKey contextKey = "tenant"
const (
sigV4DateFormat = "20060102T150405Z"
maxClockSkew = 15 * time.Minute
)
// AuthMiddleware — middleware: ищет тенанта по AccessKeyId из AWS Authorization header.
// Пропускает /health и /admin/** без tenant-аутентификации.
// Ловушка #4: не ставим короткий таймаут — ReceiveMessage с long polling держит соединение до 20 сек.
func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// /health — без auth
if r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
// /admin/** — отдельная auth (bearer token, см. admin_handlers.go)
if strings.HasPrefix(r.URL.Path, "/admin/") {
next.ServeHTTP(w, r)
return
}
accessKeyID := extractAccessKeyID(r)
if accessKeyID == "" {
writeSQSAuthError(w, "MissingAuthenticationToken", "Request must contain either AccessKeyId or X-Amz-Credential")
return
}
t, ok := store.GetByAccessKey(accessKeyID)
if !ok || !t.Active {
writeSQSAuthError(w, "InvalidClientTokenId", "The security token included in the request is invalid")
return
}
if err := verifySigV4(r, t); err != nil {
writeSQSAuthError(w, "SignatureDoesNotMatch", err.Error())
return
}
ctx := context.WithValue(r.Context(), TenantContextKey, t)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// extractAccessKeyID — извлекает AWS AccessKeyId из запроса.
// Поддерживает оба варианта: Authorization header (Signature V4) и X-Amz-Credential query param (presigned URLs).
// Ловушка #3: AWS CLI ВСЕГДА отправляет Signature V4 — нужно парсить, даже не проверяя подпись.
// Ловушка #5: X-Amz-Security-Token (STS) — игнорируем.
func extractAccessKeyID(r *http.Request) string {
// Вариант 1: Authorization header
// Формат: "AWS4-HMAC-SHA256 Credential={AccessKeyId}/{date}/{region}/sqs/aws4_request, ..."
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "AWS4-HMAC-SHA256") {
idx := strings.Index(auth, "Credential=")
if idx >= 0 {
rest := auth[idx+len("Credential="):]
slashIdx := strings.Index(rest, "/")
if slashIdx > 0 {
return rest[:slashIdx]
}
}
}
// Вариант 2: Query parameter (presigned URLs)
// Формат: X-Amz-Credential={AccessKeyId}/{date}/{region}/sqs/aws4_request
if cred := r.URL.Query().Get("X-Amz-Credential"); cred != "" {
parts := strings.SplitN(cred, "/", 2)
if len(parts) > 0 && parts[0] != "" {
return parts[0]
}
}
return ""
}
// verifySigV4 — полноценная проверка подписи AWS SigV4 для header и presigned запросов.
func verifySigV4(r *http.Request, t *tenant.Tenant) error {
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") {
return verifyHeaderSigV4(r, t, authHeader)
}
if r.URL.Query().Get("X-Amz-Credential") != "" {
return verifyPresignedSigV4(r, t)
}
return errors.New("missing SigV4 authorization")
}
func verifyHeaderSigV4(r *http.Request, t *tenant.Tenant, authHeader string) error {
credential, signedHeaders, providedSignature, err := parseAuthorizationHeader(authHeader)
if err != nil {
return err
}
accessKey, shortDate, region, service, terminal, err := parseCredentialScope(credential)
if err != nil {
return err
}
if accessKey != t.AccessKey {
return errors.New("access key mismatch")
}
if service != "sqs" || terminal != "aws4_request" {
return errors.New("invalid credential scope")
}
amzDate := r.Header.Get("X-Amz-Date")
if amzDate == "" {
return errors.New("missing X-Amz-Date header")
}
requestTime, err := time.Parse(sigV4DateFormat, amzDate)
if err != nil {
return fmt.Errorf("invalid X-Amz-Date: %w", err)
}
if absDuration(time.Now().UTC().Sub(requestTime)) > maxClockSkew {
return errors.New("request time skew is too large")
}
body, err := readAndRestoreBody(r)
if err != nil {
return err
}
payloadHash := r.Header.Get("X-Amz-Content-Sha256")
if payloadHash == "" {
payloadHash = sha256Hex(body)
}
canonicalRequest, err := buildCanonicalRequest(r, signedHeaders, payloadHash, true)
if err != nil {
return err
}
credentialScope := strings.Join([]string{shortDate, region, service, terminal}, "/")
stringToSign := buildStringToSign(amzDate, credentialScope, canonicalRequest)
expectedSignature := calculateSigV4Signature(t.SecretKey, shortDate, region, service, stringToSign)
if subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignature)), []byte(strings.ToLower(providedSignature))) != 1 {
return errors.New("signature mismatch")
}
return nil
}
func verifyPresignedSigV4(r *http.Request, t *tenant.Tenant) error {
query := r.URL.Query()
if query.Get("X-Amz-Algorithm") != "AWS4-HMAC-SHA256" {
return errors.New("unsupported X-Amz-Algorithm")
}
credential := query.Get("X-Amz-Credential")
if credential == "" {
return errors.New("missing X-Amz-Credential")
}
providedSignature := query.Get("X-Amz-Signature")
if providedSignature == "" {
return errors.New("missing X-Amz-Signature")
}
signedHeaders := query.Get("X-Amz-SignedHeaders")
if signedHeaders == "" {
return errors.New("missing X-Amz-SignedHeaders")
}
amzDate := query.Get("X-Amz-Date")
if amzDate == "" {
return errors.New("missing X-Amz-Date")
}
expiresSeconds, err := strconv.Atoi(query.Get("X-Amz-Expires"))
if err != nil || expiresSeconds < 1 || expiresSeconds > 604800 {
return errors.New("invalid X-Amz-Expires")
}
requestTime, err := time.Parse(sigV4DateFormat, amzDate)
if err != nil {
return fmt.Errorf("invalid X-Amz-Date: %w", err)
}
if time.Now().UTC().After(requestTime.Add(time.Duration(expiresSeconds) * time.Second)) {
return errors.New("presigned request has expired")
}
accessKey, shortDate, region, service, terminal, err := parseCredentialScope(credential)
if err != nil {
return err
}
if accessKey != t.AccessKey {
return errors.New("access key mismatch")
}
if service != "sqs" || terminal != "aws4_request" {
return errors.New("invalid credential scope")
}
body, err := readAndRestoreBody(r)
if err != nil {
return err
}
payloadHash := query.Get("X-Amz-Content-Sha256")
if payloadHash == "" {
payloadHash = r.Header.Get("X-Amz-Content-Sha256")
}
if payloadHash == "" {
payloadHash = "UNSIGNED-PAYLOAD"
if len(body) > 0 {
payloadHash = sha256Hex(body)
}
}
canonicalRequest, err := buildCanonicalRequest(r, signedHeaders, payloadHash, false)
if err != nil {
return err
}
credentialScope := strings.Join([]string{shortDate, region, service, terminal}, "/")
stringToSign := buildStringToSign(amzDate, credentialScope, canonicalRequest)
expectedSignature := calculateSigV4Signature(t.SecretKey, shortDate, region, service, stringToSign)
if subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignature)), []byte(strings.ToLower(providedSignature))) != 1 {
return errors.New("signature mismatch")
}
return nil
}
func parseAuthorizationHeader(authHeader string) (credential string, signedHeaders string, signature string, err error) {
if !strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") {
return "", "", "", errors.New("unsupported authorization algorithm")
}
fields := strings.Split(strings.TrimSpace(strings.TrimPrefix(authHeader, "AWS4-HMAC-SHA256")), ",")
parts := map[string]string{}
for _, field := range fields {
kv := strings.SplitN(strings.TrimSpace(field), "=", 2)
if len(kv) != 2 {
continue
}
parts[kv[0]] = kv[1]
}
credential = parts["Credential"]
signedHeaders = strings.ToLower(parts["SignedHeaders"])
signature = parts["Signature"]
if credential == "" || signedHeaders == "" || signature == "" {
return "", "", "", errors.New("malformed Authorization header")
}
return credential, signedHeaders, signature, nil
}
func parseCredentialScope(credential string) (accessKey, shortDate, region, service, terminal string, err error) {
parts := strings.Split(credential, "/")
if len(parts) != 5 {
return "", "", "", "", "", errors.New("invalid Credential scope")
}
return parts[0], parts[1], parts[2], parts[3], parts[4], nil
}
func buildCanonicalRequest(r *http.Request, signedHeaders, payloadHash string, includeSignatureQuery bool) (string, error) {
canonicalURI := r.URL.EscapedPath()
if canonicalURI == "" {
canonicalURI = "/"
}
canonicalQuery := canonicalizeQueryString(r.URL.Query(), includeSignatureQuery)
headers := strings.Split(strings.ToLower(signedHeaders), ";")
canonicalHeaders := strings.Builder{}
cleanSignedHeaders := make([]string, 0, len(headers))
for _, h := range headers {
name := strings.TrimSpace(h)
if name == "" {
continue
}
value, ok := canonicalHeaderValue(r, name)
if !ok {
return "", fmt.Errorf("missing signed header: %s", name)
}
canonicalHeaders.WriteString(name)
canonicalHeaders.WriteString(":")
canonicalHeaders.WriteString(normalizeHeaderSpace(value))
canonicalHeaders.WriteString("\n")
cleanSignedHeaders = append(cleanSignedHeaders, name)
}
if len(cleanSignedHeaders) == 0 {
return "", errors.New("empty signed headers")
}
return strings.Join([]string{
r.Method,
canonicalURI,
canonicalQuery,
canonicalHeaders.String(),
strings.Join(cleanSignedHeaders, ";"),
payloadHash,
}, "\n"), nil
}
func canonicalHeaderValue(r *http.Request, name string) (string, bool) {
if name == "host" {
h := r.Host
if h == "" {
h = r.URL.Host
}
return h, h != ""
}
key := http.CanonicalHeaderKey(name)
values, ok := r.Header[key]
if !ok || len(values) == 0 {
return "", false
}
return strings.Join(values, ","), true
}
func canonicalizeQueryString(query url.Values, includeSignature bool) string {
pairs := make([]string, 0)
for key, values := range query {
if !includeSignature && strings.EqualFold(key, "X-Amz-Signature") {
continue
}
sortedValues := append([]string(nil), values...)
sort.Strings(sortedValues)
if len(sortedValues) == 0 {
pairs = append(pairs, awsPercentEncode(key)+"=")
continue
}
for _, value := range sortedValues {
pairs = append(pairs, awsPercentEncode(key)+"="+awsPercentEncode(value))
}
}
sort.Strings(pairs)
return strings.Join(pairs, "&")
}
func awsPercentEncode(s string) string {
encoded := url.QueryEscape(s)
encoded = strings.ReplaceAll(encoded, "+", "%20")
encoded = strings.ReplaceAll(encoded, "*", "%2A")
encoded = strings.ReplaceAll(encoded, "%7E", "~")
return encoded
}
func normalizeHeaderSpace(v string) string {
return strings.Join(strings.Fields(strings.TrimSpace(v)), " ")
}
func buildStringToSign(amzDate, credentialScope, canonicalRequest string) string {
canonicalHash := sha256.Sum256([]byte(canonicalRequest))
return strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
hex.EncodeToString(canonicalHash[:]),
}, "\n")
}
func calculateSigV4Signature(secretKey, shortDate, region, service, stringToSign string) string {
kDate := hmacSHA256([]byte("AWS4"+secretKey), shortDate)
kRegion := hmacSHA256(kDate, region)
kService := hmacSHA256(kRegion, service)
kSigning := hmacSHA256(kService, "aws4_request")
sig := hmacSHA256(kSigning, stringToSign)
return hex.EncodeToString(sig)
}
func hmacSHA256(key []byte, data string) []byte {
m := hmac.New(sha256.New, key)
_, _ = m.Write([]byte(data))
return m.Sum(nil)
}
func readAndRestoreBody(r *http.Request) ([]byte, error) {
if r.Body == nil {
return nil, nil
}
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("read request body: %w", err)
}
r.Body = io.NopCloser(bytes.NewReader(body))
return body, nil
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func absDuration(d time.Duration) time.Duration {
if d < 0 {
return -d
}
return d
}
// sqsAuthError — AWS-совместимый XML ответ об ошибке аутентификации.
type sqsAuthError struct {
XMLName xml.Name `xml:"ErrorResponse"`
Error sqsErrorBody `xml:"Error"`
RequestID string `xml:"RequestId"`
}
type sqsErrorBody struct {
Type string `xml:"Type"`
Code string `xml:"Code"`
Message string `xml:"Message"`
}
// writeSQSAuthError — отвечает AWS-совместимым XML с кодом 403.
func writeSQSAuthError(w http.ResponseWriter, code, message string) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusForbidden)
resp := sqsAuthError{
Error: sqsErrorBody{
Type: "Sender",
Code: code,
Message: message,
},
RequestID: "00000000-0000-0000-0000-000000000000",
}
data, _ := xml.Marshal(resp)
w.Write(data)
}