Maksim Totmin 40b8afb150 refactor: common util package, ESL/AMI/security fixes, Prometheus metrics
Backend stability and security improvements:

* internal/util/ — common RandomHex helper, removed 3 duplicates
* ESL: deduplicated readMessage (locked/unlocked), net.JoinHostPort for IPv6
* AMI: synchronous reconnect() in readEventsLoop, net.JoinHostPort for IPv6
* Auth: /api/auth/refresh accepts Authorization header only (no ?token=)
* decodeJSON: http.MaxBytesReader(1<<20) body limit
* Trunks: gatewayParams() uses configured ESL.GatewayPrefix
* Config: jwt_secret_env env-var fallback
* FSCollector: time.After → time.NewTimer with defer Stop
* Monitoring: Prometheus counters (route_requests, nodes_total/healthy, uptime)
* go fmt pass across all internal/ packages
2026-06-25 19:30:36 +07:00

268 lines
7.4 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.

// Package api реализует HTTP API pulse-lets-go.
package api
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"github.com/pulse-lets-go/internal/models"
)
// JWT claims
type claims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// contextKey — тип для ключей context.
type contextKey string
const (
userContextKey contextKey = "user"
refreshTokenKey contextKey = "refresh_token"
)
// UserInfo — информация о пользователе, извлекаемая из JWT.
type UserInfo struct {
UserID string
Username string
Role string
}
const (
jwtExpiry = 15 * time.Minute
refreshExpiry = 24 * time.Hour
)
// generateJWT создаёт access+refresh токены.
func (a *API) generateJWT(user *models.User) (*models.LoginResponse, error) {
now := time.Now()
// Access token
accessClaims := claims{
UserID: user.ID,
Username: user.Username,
Role: string(user.Role),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(jwtExpiry)),
IssuedAt: jwt.NewNumericDate(now),
},
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(a.jwtSecret))
if err != nil {
return nil, err
}
// Refresh token
refreshClaims := claims{
UserID: user.ID,
Username: user.Username,
Role: string(user.Role),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(refreshExpiry)),
IssuedAt: jwt.NewNumericDate(now),
},
}
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(a.jwtSecret))
if err != nil {
return nil, err
}
return &models.LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int64(jwtExpiry.Seconds()),
}, nil
}
// authMiddleware — проверяет JWT в заголовке Authorization.
func (a *API) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := extractBearerToken(r)
if tokenStr == "" {
writeError(w, http.StatusUnauthorized, "требуется авторизация")
return
}
c := &claims{}
token, err := jwt.ParseWithClaims(tokenStr, c, func(token *jwt.Token) (interface{}, error) {
return []byte(a.jwtSecret), nil
})
if err != nil || !token.Valid {
writeError(w, http.StatusUnauthorized, "недействительный токен")
return
}
user := &UserInfo{
UserID: c.UserID,
Username: c.Username,
Role: c.Role,
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// adminOnly — middleware проверки роли admin.
func (a *API) adminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil || user.Role != string(models.RoleAdmin) {
writeError(w, http.StatusForbidden, "требуется роль admin")
return
}
next.ServeHTTP(w, r)
})
}
// apiKeyMiddleware — проверяет API-key для monitoring endpoint.
func (a *API) apiKeyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" || key != a.monitoringAPIKey {
writeError(w, http.StatusUnauthorized, "недействительный API-ключ")
return
}
next.ServeHTTP(w, r)
})
}
// handleLogin обрабатывает POST /api/auth/login.
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
var req models.LoginRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON")
return
}
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "имя и пароль обязательны")
return
}
users, err := a.configManager.ReadUsers()
if err != nil {
log.Printf("[auth] ошибка чтения users.json: %v", err)
writeError(w, http.StatusInternalServerError, "ошибка сервера")
return
}
var found *models.User
for i := range users {
if users[i].Username == req.Username {
found = &users[i]
break
}
}
if found == nil {
writeError(w, http.StatusUnauthorized, "неверное имя или пароль")
return
}
// Если пароль не задан (плейсхолдер), разрешаем вход с паролем "admin"
if found.PasswordHash == "" {
if req.Password != "admin" {
writeError(w, http.StatusUnauthorized, "неверное имя или пароль")
return
}
// Хешируем пароль и сохраняем
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
found.PasswordHash = string(hash)
a.configManager.SaveUsers(users)
} else {
if err := bcrypt.CompareHashAndPassword([]byte(found.PasswordHash), []byte(req.Password)); err != nil {
writeError(w, http.StatusUnauthorized, "неверное имя или пароль")
return
}
}
resp, err := a.generateJWT(found)
if err != nil {
log.Printf("[auth] ошибка генерации JWT: %v", err)
writeError(w, http.StatusInternalServerError, "ошибка сервера")
return
}
writeJSON(w, http.StatusOK, resp)
}
// handleRefresh выдаёт новый access токен по refresh токену.
func (a *API) handleRefresh(w http.ResponseWriter, r *http.Request) {
tokenStr := extractBearerToken(r)
if tokenStr == "" {
writeError(w, http.StatusUnauthorized, "требуется токен")
return
}
c := &claims{}
token, err := jwt.ParseWithClaims(tokenStr, c, func(token *jwt.Token) (interface{}, error) {
return []byte(a.jwtSecret), nil
})
if err != nil || !token.Valid {
writeError(w, http.StatusUnauthorized, "недействительный refresh токен")
return
}
user := &models.User{
ID: c.UserID,
Username: c.Username,
Role: models.UserRole(c.Role),
}
resp, err := a.generateJWT(user)
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка сервера")
return
}
writeJSON(w, http.StatusOK, resp)
}
// --- Хелперы ---
// extractBearerToken извлекает JWT из заголовка Authorization: Bearer <token>.
func extractBearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if auth == "" {
return ""
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return ""
}
return parts[1]
}
// GetUserFromContext извлекает UserInfo из контекста запроса.
func GetUserFromContext(ctx context.Context) *UserInfo {
user, ok := ctx.Value(userContextKey).(*UserInfo)
if !ok {
return nil
}
return user
}
// writeJSON отправляет JSON-ответ.
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// writeError отправляет JSON-ошибку.
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}