125 lines
4.4 KiB
Go
125 lines
4.4 KiB
Go
// Изменено: 2026-04-10
|
||
// jwt.go — JWT-парсинг и валидация через nubes API (deck-api).
|
||
// Извлекает sub (UUID пользователя) и email из JWT claims.
|
||
// Валидация: GET к nubes API с Bearer token → если 401/403 → отклонён.
|
||
// Подпись JWT НЕ проверяется — аналогично sless (trusted perimeter).
|
||
package auth
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// JWTClaims — минимальный набор claims из nubes JWT.
|
||
// Не все поля заполнены у каждого пользователя — email может быть пустым.
|
||
type JWTClaims struct {
|
||
Sub string `json:"sub"`
|
||
Email string `json:"email"`
|
||
Exp int64 `json:"exp"`
|
||
Iss string `json:"iss"`
|
||
}
|
||
|
||
// ParseJWTClaims — декодирует JWT payload (base64url) и возвращает claims.
|
||
// Проверяет: структуру JWT (3 части), наличие sub, не истёк ли exp.
|
||
// Подпись НЕ проверяется — валидация через PingNubesAPI.
|
||
func ParseJWTClaims(token string) (*JWTClaims, error) {
|
||
parts := strings.Split(token, ".")
|
||
if len(parts) != 3 {
|
||
return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts))
|
||
}
|
||
|
||
// JWT использует base64url без padding — добавляем
|
||
payload := parts[1]
|
||
switch len(payload) % 4 {
|
||
case 2:
|
||
payload += "=="
|
||
case 3:
|
||
payload += "="
|
||
}
|
||
|
||
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||
if err != nil {
|
||
// Пробуем StdEncoding на случай нестандартного токена
|
||
decoded, err = base64.StdEncoding.DecodeString(payload)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decode JWT payload: %w", err)
|
||
}
|
||
}
|
||
|
||
var claims JWTClaims
|
||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||
return nil, fmt.Errorf("parse JWT claims: %w", err)
|
||
}
|
||
if claims.Sub == "" {
|
||
return nil, fmt.Errorf("JWT missing 'sub' claim")
|
||
}
|
||
if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
|
||
return nil, fmt.Errorf("JWT token expired")
|
||
}
|
||
return &claims, nil
|
||
}
|
||
|
||
// ErrTokenRejected — шлюз явно отклонил токен (401/403).
|
||
// Sentinel, чтобы отличать отказ от сетевых ошибок (см. cache.go).
|
||
var ErrTokenRejected = errors.New("token rejected by nubes API")
|
||
|
||
// nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен.
|
||
// Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял.
|
||
// Юзер может вводить токен от любого стенда — главное, что токен действительный.
|
||
var nubesAPIEndpoints = []string{
|
||
"https://lk-api-gateway.ngcloud.ru/api/v1/svc",
|
||
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
|
||
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
|
||
}
|
||
|
||
// PingNubesAPI — проверяет валидность токена перебором стендов Nubes API.
|
||
// endpoint (если задан через NUBES_ENDPOINT) пробуется первым.
|
||
// Логика: 401/403 → токен отклонён ЭТИМ стендом, пробуем следующий.
|
||
// Любой другой HTTP статус → токен принят.
|
||
func PingNubesAPI(ctx context.Context, endpoint, token string) error {
|
||
candidates := make([]string, 0, len(nubesAPIEndpoints)+1)
|
||
if endpoint != "" {
|
||
candidates = append(candidates, endpoint)
|
||
}
|
||
for _, ep := range nubesAPIEndpoints {
|
||
if ep != endpoint {
|
||
candidates = append(candidates, ep)
|
||
}
|
||
}
|
||
|
||
var lastErr error
|
||
for _, ep := range candidates {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
|
||
if err != nil {
|
||
lastErr = fmt.Errorf("build nubes ping request: %w", err)
|
||
continue
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||
|
||
client := &http.Client{Timeout: 10 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
lastErr = fmt.Errorf("nubes API unreachable at %s: %w", ep, err)
|
||
continue
|
||
}
|
||
resp.Body.Close()
|
||
|
||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||
lastErr = fmt.Errorf("%w (HTTP %d)", ErrTokenRejected, resp.StatusCode)
|
||
continue
|
||
}
|
||
return nil
|
||
}
|
||
if lastErr == nil {
|
||
lastErr = fmt.Errorf("nubes API rejected token")
|
||
}
|
||
return lastErr
|
||
}
|