Загрузка данных
```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), ¤t); 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), ¤t); 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)
}
}
```