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
268 lines
7.4 KiB
Go
268 lines
7.4 KiB
Go
// 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})
|
||
}
|