Загрузка данных


```go
package main

import (
	"auth/internal"
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"net/mail"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"syscall"
	"time"

	"github.com/google/uuid"
	"golang.org/x/crypto/bcrypt"
)

// App — контейнер зависимостей (паттерн manager): все хендлеры — методы на нём.
type App struct {
	PG       *internal.PGClient
	Redis    *internal.RedisClient
	TokenTTL time.Duration
}

// Сессия пользователя — то, что лежит в "redis" по ключу token:<uuid>.
// Перенос TokenDomain.php из bh-api-auth: username, ip и устройство фиксируются
// в момент логина и сверяются при каждом запросе.
type session struct {
	Username string `json:"username"`
	IP       string `json:"ip_address"`
	DeviceID string `json:"device_id"`
	Token    string `json:"token"`
}

// HTTPError — ошибка с HTTP-кодом (паттерн manager/main.go).
type HTTPError struct {
	Code    int
	Message string
}

func (e *HTTPError) Error() string {
	return e.Message
}

func NewBadRequest(msg string) error {
	return &HTTPError{Code: http.StatusBadRequest, Message: msg}
}

func NewUnauthorized(msg string) error {
	return &HTTPError{Code: http.StatusUnauthorized, Message: msg}
}

func NewForbidden(msg string) error {
	return &HTTPError{Code: http.StatusForbidden, Message: msg}
}

func NewNotFound(msg string) error {
	return &HTTPError{Code: http.StatusNotFound, Message: msg}
}

func NewConflict(msg string) error {
	return &HTTPError{Code: http.StatusConflict, Message: msg}
}

func NewInternal(msg string) error {
	return &HTTPError{Code: http.StatusInternalServerError, Message: msg}
}

// writeJSON отправляет JSON-ответ. Ошибки — в конверте bh-api-auth:
// {"success": false, "message": "..."}, успех — {"success": true, ...}.
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 формирует конверт ошибки из любой err: HTTPError несёт свой код,
// прочее — внутренняя ошибка.
func writeError(w http.ResponseWriter, err error) {
	var httpErr *HTTPError
	if errors.As(err, &httpErr) {
		writeJSON(w, httpErr.Code, map[string]interface{}{"success": false, "message": httpErr.Message})
		return
	}
	log.Printf("internal error: %v", err)
	writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"success": false, "message": "internal error"})
}

func enableCORS(next http.Handler) http.Handler {
	return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
		responseWriter.Header().Set("Access-Control-Allow-Origin", "*")
		responseWriter.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Real-Ip")
		responseWriter.Header().Set("Access-Control-Allow-Methods", "POST, GET, PATCH, OPTIONS")
		if request.Method == http.MethodOptions {
			responseWriter.WriteHeader(http.StatusOK)
			return
		}
		next.ServeHTTP(responseWriter, request)
	})
}

// Ключи для identity, которую authMiddleware кладёт в context.
// В PHP-версии middleware дописывала uuid/username в body запроса,
// в Go идиоматичнее передавать их через context.Context.
type ctxKey int

const (
	ctxKeyUUID ctxKey = iota
	ctxKeyUsername
)

// generateToken создаёт непрозрачный токен и сессию (перенос TokenDomain.php):
//   - сам токен: 32 случайных байта в hex = 64 символа (аналог bin2hex(openssl_random_pseudo_bytes(32)));
//   - ключ <token> -> uuid: обратный индекс, чтобы по токену найти пользователя;
//   - ключ token:<uuid> -> JSON сессии: прямая запись, одна сессия на пользователя —
//     новая запись перезаписывает старую.
func (app *App) generateToken(ctx context.Context, user *internal.User, ip, deviceID string) (string, error) {
	raw := make([]byte, 32)
	if _, err := rand.Read(raw); err != nil {
		return "", err
	}
	token := hex.EncodeToString(raw)

	content, err := json.Marshal(session{
		Username: user.Username,
		IP:       ip,
		DeviceID: deviceID,
		Token:    token,
	})
	if err != nil {
		return "", err
	}

	if err := app.Redis.SetEx(ctx, token, user.UUID, app.TokenTTL); err != nil {
		return "", err
	}
	if err := app.Redis.SetEx(ctx, "token:"+user.UUID, string(content), app.TokenTTL); err != nil {
		return "", err
	}

	return token, nil
}

// deletePreviousTokens убивает прошлые сессии пользователя (аналог KEYS * из
// TokenDomain.php): удаляет все ключи, чьё значение — uuid этого пользователя.
// Так голый <token> старой сессии тоже умирает, а не ждёт конца TTL.
func (app *App) deletePreviousTokens(ctx context.Context, userUUID string) error {
	keys, err := app.Redis.Keys(ctx)
	if err != nil {
		return err
	}
	for _, key := range keys {
		value, err := app.Redis.Get(ctx, key)
		if err != nil {
			continue
		}
		if value == userUUID {
			if err := app.Redis.Del(ctx, key); err != nil {
				return err
			}
		}
	}
	return nil
}

// deleteToken удаляет текущую сессию (logout): чистим оба ключа сразу.
func (app *App) deleteToken(ctx context.Context, userUUID string) error {
	sessionKey := "token:" + userUUID

	value, err := app.Redis.Get(ctx, sessionKey)
	if errors.Is(err, internal.ErrNil) {
		return NewUnauthorized("Сессия истекла!")
	}
	if err != nil {
		return err
	}

	var current session
	if err := json.Unmarshal([]byte(value), &current); err != nil {
		return NewInternal("broken session data")
	}

	return app.Redis.Del(ctx, sessionKey, current.Token)
}

// authMiddleware — перенос TokenHandler.php: Bearer-токен из заголовка
// Authorization, двухшаговый поиск (<token> -> uuid, token:<uuid> -> сессия),
// сверка токена и IP из X-Real-Ip с записанным при логине.
// device_id (User-Agent) хранится, но не проверяется — как в bh-api-auth.
func (app *App) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		authorization := r.Header.Get("Authorization")
		token, ok := strings.CutPrefix(authorization, "Bearer ")
		if !ok || token == "" {
			writeJSON(w, http.StatusUnauthorized, map[string]interface{}{"success": false, "message": "Unauthorized"})
			return
		}

		ctx := r.Context()

		userUUID, err := app.Redis.Get(ctx, token)
		if err != nil {
			writeJSON(w, http.StatusUnauthorized, map[string]interface{}{"success": false, "message": "Unauthorized"})
			return
		}

		value, err := app.Redis.Get(ctx, "token:"+userUUID)
		if err != nil {
			writeJSON(w, http.StatusUnauthorized, map[string]interface{}{"success": false, "message": "Unauthorized"})
			return
		}

		var current session
		if err := json.Unmarshal([]byte(value), &current); err != nil {
			writeJSON(w, http.StatusUnauthorized, map[string]interface{}{"success": false, "message": "Unauthorized"})
			return
		}

		if current.Token != token || current.IP != r.Header.Get("X-Real-Ip") {
			writeJSON(w, http.StatusUnauthorized, map[string]interface{}{"success": false, "message": "Unauthorized"})
			return
		}

		// Идентичность пользователя — в context, хендлеры читают её оттуда.
		ctx = context.WithValue(ctx, ctxKeyUUID, userUUID)
		ctx = context.WithValue(ctx, ctxKeyUsername, current.Username)
		next(w, r.WithContext(ctx))
	}
}

// userUUIDFromContext достаёт uuid аутентифицированного пользователя.
func userUUIDFromContext(ctx context.Context) string {
	uuid, _ := ctx.Value(ctxKeyUUID).(string)
	return uuid
}

// decodeJSON читает и декодирует тело запроса в v.
func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
	if err := json.NewDecoder(r.Body).Decode(v); err != nil {
		return NewBadRequest("Invalid JSON body: " + err.Error())
	}
	return nil
}

// validateEmailAndPassword — общие проверки для register/login.
// Пароль 8–50 символов, как в bh-api-auth (Validator::processPassword).
func validateEmailAndPassword(email, password string) error {
	if email == "" {
		return NewBadRequest("Missing required parameter: email")
	}
	if password == "" {
		return NewBadRequest("Missing required parameter: password")
	}
	if _, err := mail.ParseAddress(email); err != nil {
		return NewBadRequest("Некорректный логин!")
	}
	if len(password) < 8 || len(password) > 50 {
		return NewBadRequest("Неверный пароль!")
	}
	return nil
}

// clientIP возвращает IP запроса: как в bh-api-auth, доверяем X-Real-Ip
// (за сервисом стоит прокси, который его выставляет).
func clientIP(r *http.Request) string {
	return r.Header.Get("X-Real-Ip")
}

// registerHandler — POST /api/auth/register.
// Упрощённый сценарий bh-api-auth: без send/verify, аккаунт сразу активен,
// в ответе сразу токен (пользователь считается залогиненным).
func (app *App) registerHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"success": false, "message": "Only POST"})
		return
	}

	var request struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := decodeJSON(w, r, &request); err != nil {
		writeError(w, err)
		return
	}
	request.Email = strings.ToLower(strings.TrimSpace(request.Email))

	if err := validateEmailAndPassword(request.Email, request.Password); err != nil {
		writeError(w, err)
		return
	}

	passwordHash, err := bcrypt.GenerateFromPassword([]	byte(request.Password), bcrypt.DefaultCost)
	if err != nil {
		writeError(w, NewInternal("Ошибка при регистрации"))
		return
	}

	user := &internal.User{
		UUID:         uuid.New().String(),
		Email:        request.Email,
		PasswordHash: string(passwordHash),
	}
	if err := app.PG.CreateUser(r.Context(), user); err != nil {
		if errors.Is(err, internal.ErrEmailExists) {
			writeError(w, NewConflict("Такой аккаунт уже существует!"))
			return
		}
		writeError(w, NewInternal("Ошибка при регистрации"))
		return
	}
	log.Printf("зарегистрирован пользователь %s (%s)", user.UUID, user.Email)

	token, err := app.generateToken(r.Context(), user, clientIP(r), r.UserAgent())
	if err != nil {
		writeError(w, NewInternal("Ошибка при регистрации"))
	return
	}

	writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "token": token})
}

// loginHandler — POST /api/auth/login. Сценарий bh-api-auth:
// не найден — 404, неверный пароль — 403, успех убивает прошлые сессии
// и выдаёт новый токен (одна сессия на пользователя).
func (app *App) loginHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"success": false, "message": "Only POST"})
		return
	}

	var request struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := decodeJSON(w, r, &request); err != nil {
		writeError(w, err)
		return
	}
	request.Email = strings.ToLower(strings.TrimSpace(request.Email))

	if err := validateEmailAndPassword(request.Email, request.Password); err != nil {
		writeError(w, err)
		return
	}

	user, err := app.PG.GetUserByEmail(r.Context(), request.Email)
	if err != nil {
		writeError(w, NewInternal("internal error"))
		return
	}
	if user == nil {
		writeError(w, NewNotFound("Пользователь не найден"))
		return
	}

	if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(request.Password)); err != nil {
		writeError(w, NewForbidden("Неверный пароль!"))
		return
	}

	if err := app.deletePreviousTokens(r.Context(), user.UUID); err != nil {
		writeError(w, NewInternal("internal error"))
		return
	}

	token, err := app.generateToken(r.Context(), user, clientIP(r), r.UserAgent())
	if err != nil {
		writeError(w, NewInternal("internal error"))
		return
	}
	log.Printf("вход пользователя %s (%s)", user.UUID, user.Email)

	writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "token": token})
}

// logoutHandler — POST /api/auth/logout. Удаляет сессию в "redis".
func (app *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"success": false, "message": "Only POST"})
		return
	}

	if err := app.deleteToken(r.Context(), userUUIDFromContext(r.Context())); err != nil {
		writeError(w, err)
		return
	}

	writeJSON(w, http.StatusOK, map[string]interface{}{"success": true})
}

// userHandler — /api/user/{uuid}: GET отдаёт информацию о пользователе по id,
// PATCH редактирует его. Редактировать можно только себя: токен должен
// принадлежать редактируемому пользователю, иначе 403.
// Один хендлер на оба метода, потому что ServeMux не даёт регистрировать
// один путь дважды; метод проверяется внутри (как в manager).
func (app *App) userHandler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		app.userGet(w, r)
	case http.MethodPatch:
		app.userPatch(w, r)
	default:
		writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"success": false, "message": "Only GET or PATCH"})
	}
}

// userGet — GET /api/user/{uuid}. Информация о пользователе по id (по токену).
func (app *App) userGet(w http.ResponseWriter, r *http.Request) {
	user, err := app.PG.GetUserByUUID(r.Context(), r.PathValue("uuid"))
	if err != nil {
		writeError(w, NewInternal("internal error"))
		return
	}
	if user == nil {
		writeError(w, NewNotFound("Пользователь не найден"))
		return
	}

	writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "user": user.Public()})
}

// userPatch — PATCH /api/user/{uuid}. Редактирование: обновляются только поля,
// явно переданные в теле (nil в UserPatch = не трогаем). Редактируемый
// пользователь определяется id в пути, но токен должен быть его собственным.
func (app *App) userPatch(w http.ResponseWriter, r *http.Request) {
	targetUUID := r.PathValue("uuid")
	if userUUIDFromContext(r.Context()) != targetUUID {
		writeError(w, NewForbidden("Forbidden"))
		return
	}

	var patch internal.UserPatch
	if err := decodeJSON(w, r, &patch); err != nil {
		writeError(w, err)
		return
	}
	if patch.Username == nil && patch.FirstName == nil && patch.LastName == nil {
		writeError(w, NewBadRequest("Пустой запрос: передайте хотя бы одно поле"))
		return
	}

	user, err := app.PG.UpdateUser(r.Context(), targetUUID, &patch)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			writeError(w, NewNotFound("Пользователь не найден"))
			return
		}
		writeError(w, NewInternal("Ошибка при обновлении"))
		return
	}

	writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "user": user.Public()})
}

func main() {
	// Конфигурация — как в manager: os.Getenv + дефолты инлайн.
	port := os.Getenv("AUTH_PORT")
	if port == "" {
		port = "8080"
	}

	tokenExpiration, _ := strconv.Atoi(os.Getenv("TOKEN_EXPIRATION"))
	if tokenExpiration == 0 {
		tokenExpiration = 259200 // 3 суток, как в bh-api-auth
	}

	pgClient, err := internal.NewPGClient(
		os.Getenv("PG_HOST"),
		os.Getenv("PG_PORT"),
		os.Getenv("PG_USER"),
		os.Getenv("PG_PASSWORD"),
		os.Getenv("PG_NAME"),
		os.Getenv("PG_SSL_MODE"),
	)
	if err != nil {
		log.Fatalf("Fatal: Failed to connect to Postgres: %v", err)
	}
	defer pgClient.Close()

	redisClient := internal.NewRedisClient()
	defer redisClient.Stop()

	app := &App{
		PG:       pgClient,
		Redis:    redisClient,
		TokenTTL: time.Duration(tokenExpiration) * time.Second,
	}

	mux := http.NewServeMux()

	mux.HandleFunc("/api/auth/register", app.registerHandler)
	mux.HandleFunc("/api/auth/login", app.loginHandler)
	mux.HandleFunc("/api/auth/logout", app.authMiddleware(app.logoutHandler))
	mux.HandleFunc("/api/user/{uuid}", app.authMiddleware(app.userHandler))

	server := &http.Server{
		Addr:         ":" + port,
		Handler:      enableCORS(mux),
		ReadTimeout:  30 * time.Second,
		WriteTimeout: 30 * time.Second,
		IdleTimeout:  120 * time.Second,
	}

	// Graceful shutdown: по SIGINT/SIGTERM останавливаем HTTP-сервер,
	// janitor-горутина и пул соединений закрываются defer-ами выше.
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		log.Printf("Auth-сервис запущен на :%s (TOKEN_EXPIRATION=%ds)", port, tokenExpiration)
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Fatal: server error: %v", err)
		}
	}()

	<-ctx.Done()
	log.Printf("получен сигнал завершения, останавливаемся...")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := server.Shutdown(shutdownCtx); err != nil {
		log.Printf("ошибка при остановке сервера: %v", err)
	}
}
```