Лекція 12. Валідація даних, безпека та документування у Gin
🎯 Мета лекції
- Опанувати механізм зв'язування даних (Model Binding) у Gin та автоматичну валідацію через теги структур.
- Зрозуміти роботу з багатокомпонентними запитами (Multipart) та завантаженням файлів.
- Навчитися впроваджувати систему автентифікації на основі JWT токенів із підтримкою ротації.
- Освоїти автоматичну генерацію інтерактивної документації API за стандартом OpenAPI 3.0 / Swagger.
🔑 Ключові терміни
- Model Binding: автоматичне зв'язування даних із HTTP-запиту зі структурою Go.
- DTO (Data Transfer Object): об'єкт передачі даних, що описує структуру вхідних/вихідних даних API.
- JWT (JSON Web Token): стандарт токенів для автентифікації, що містить підписані claims.
- OpenAPI / Swagger: специфікація опису REST API та інтерфейс для інтерактивного тестування.
Зв'язування даних: від HTTP до структур Go
На попередній лекції ми навчилися обробляти запити та повертати відповіді через gin.Context. Але у реальних застосунках клієнти надсилають складні дані: JSON-тіла запитів, параметри запиту (query parameters), параметри маршруту (path parameters), дані форм та файли.
Зв'язування даних (Model Binding) — це процес автоматичного перетворення даних із HTTP-запиту у типізовану структуру Go. Gin надає сімейство методів Bind* та ShouldBind*, які значно спрощують парсинг та валідацію вхідних даних.
Концепція DTO та теги структур
DTO (Data Transfer Object) — це структура, яка описує формат даних, що передаються між клієнтом та сервером. У Go DTO реалізуються як звичайні структури із спеціальними тегами (struct tags), які визначають:
- Як поля серіалізуються/десеріалізуються (теги
json,xml,form) - Які правила валідації застосовуються (тег
binding) - Як документувати поля для Swagger (тег
example)
Розглянемо типову DTO для реєстрації користувача:
package dto
// RegisterRequest описує дані для реєстрації нового користувача
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=72"`
FullName string `json:"full_name" binding:"required,min=2,max=100"`
Age int `json:"age" binding:"omitempty,gte=18,lte=120"`
}
Розбір тегів:
json:"email"— поле буде десеріалізовано з ключаemailу JSON-тілі запиту.binding:"required"— поле є обов'язковим (не може бути порожнім або відсутнім).binding:"email"— значення має відповідати формату email адреси.binding:"min=8,max=72"— довжина рядка має бути від 8 до 72 символів.binding:"omitempty"— якщо поле не передано, валідація не застосовується (поле необов'язкове).binding:"gte=18,lte=120"— числове значення має бути більше або дорівнювати 18 та менше або дорівнювати 120.
binding використовують бібліотеку go-playground/validator/v10, яка підтримує понад 100 вбудованих правил валідації. Повний список доступний у документації: github.com/go-playground/validator.Методи зв'язування у Gin
Gin надає дві групи методів для зв'язування даних:
1. Група Bind* — автоматичне визначення джерела:
func (h *Handler) Register(c *gin.Context) {
var req dto.RegisterRequest
// Bind автоматично визначає Content-Type та парсить дані
if err := c.Bind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// req тепер містить перевірені дані
}
Метод Bind() автоматично визначає тип вмісту за заголовком Content-Type:
application/json→c.BindJSON()application/xml→c.BindXML()application/x-www-form-urlencoded→c.BindForm()multipart/form-data→c.BindMultipart()
2. Група ShouldBind* — явне вказування джерела:
Ці методи надають більший контроль та не встановлюють код статусу автоматично:
// Зв'язування JSON-тіла
if err := c.ShouldBindJSON(&req); err != nil {
// Обробка помилки валідації
}
// Зв'язування query-параметрів (?page=1&limit=20)
if err := c.ShouldBindQuery(&pagination); err != nil {
// Обробка помилки
}
// Зв'язування параметрів маршруту (/users/:id)
if err := c.ShouldBindUri(&uriParams); err != nil {
// Обробка помилки
}
// Зв'язування даних форми
if err := c.ShouldBindForm(&formData); err != nil {
// Обробка помилки
}
Bind* vs ShouldBind*?- Використовуйте
ShouldBind*, коли потрібен детальний контроль над обробкою помилок валідації. Bind*автоматично встановлює статус400 Bad Requestпри помилці, що зручно для швидкої розробки.- У промисловому коді краще використовувати
ShouldBind*та формувати стандартизовані відповіді про помилки.
Приклад реального обробника з валідацією
Розглянемо повний приклад обробника реєстрації користувача із валідацією даних:
package handler
import (
"net/http"
"myapp/internal/dto"
"myapp/internal/usecase"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
userUseCase usecase.UserUseCase
}
func NewUserHandler(uc usecase.UserUseCase) *UserHandler {
return &UserHandler{userUseCase: uc}
}
// Register обробляє реєстрацію нового користувача
// @Summary Реєстрація користувача
// @Tags Users
// @Accept json
// @Produce json
// @Param request body dto.RegisterRequest true "Дані користувача"
// @Success 201 {object} dto.UserResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/users/register [post]
func (h *UserHandler) Register(c *gin.Context) {
var req dto.RegisterRequest
// Зв'язування та валідація JSON-тіла
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{
Error: "Validation failed",
Details: err.Error(),
})
return
}
// Передача валідованих даних у бізнес-логіку
user, err := h.userUseCase.RegisterUser(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{
Error: "Registration failed",
Details: err.Error(),
})
return
}
// Успішна відповідь
c.JSON(http.StatusCreated, dto.UserResponse{
ID: user.ID,
Email: user.Email,
FullName: user.FullName,
CreatedAt: user.CreatedAt,
})
}
@. Це анотації Swagger, які використовуються для автоматичної генерації документації API. Ми розглянемо їх детально у розділі про документування.Декларативна валідація: правила та кастомні валідатори
Бібліотека go-playground/validator підтримує широкий спектр правил валідації. Розглянемо найпоширеніші категорії.
Базові правила валідації
Обов'язковість полів:
type LoginRequest struct {
Email string `binding:"required"` // Поле обов'язкове
Password string `binding:"required"`
Remember bool `binding:"omitempty"` // Необов'язкове поле
}
Валідація рядків:
type ProductDTO struct {
Name string `binding:"required,min=3,max=100"` // Довжина рядка
SKU string `binding:"required,alphanum"` // Лише букви та цифри
Description string `binding:"omitempty,max=500"`
URL string `binding:"omitempty,url"` // Формат URL
Email string `binding:"required,email"` // Формат email
}
Валідація чисел:
type PaginationQuery struct {
Page int `form:"page" binding:"required,min=1"` // Мінімум 1
Limit int `form:"limit" binding:"required,min=1,max=100"` // Діапазон 1-100
}
type PriceDTO struct {
Amount float64 `json:"amount" binding:"required,gt=0"` // Більше нуля
Discount float64 `json:"discount" binding:"omitempty,gte=0,lte=100"` // 0-100%
}
Валідація масивів та зрізів:
type OrderDTO struct {
Items []string `binding:"required,min=1,max=50,dive,required"` // Мін 1 елемент, макс 50
Tags []string `binding:"omitempty,dive,alphanum"` // Кожен елемент alphanum
}
Директива dive вказує валідатору застосувати правила після коми до кожного елемента масиву.
Валідація вкладених структур:
type Address struct {
Street string `binding:"required"`
City string `binding:"required"`
ZipCode string `binding:"required,len=5"` // Рівно 5 символів
}
type UserProfile struct {
Name string `binding:"required"`
Address Address `binding:"required"` // Валідує вкладену структуру
}
Створення власних правил валідації
Іноді вбудованих правил недостатньо для специфічних бізнес-вимог. Gin дозволяє реєструвати власні функції валідації.
Припустимо, нам потрібно валідувати, що пароль містить хоча б одну велику літеру, одну малу літеру та одну цифру:
package validator
import (
"regexp"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// strongPassword перевіряє складність пароля
func strongPassword(fl validator.FieldLevel) bool {
password := fl.Field().String()
// Перевірка наявності великої літери
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
// Перевірка наявності малої літери
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
// Перевірка наявності цифри
hasDigit := regexp.MustCompile(`[0-9]`).MatchString(password)
return hasUpper && hasLower && hasDigit
}
// RegisterCustomValidators реєструє всі кастомні валідатори
func RegisterCustomValidators() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("strongpwd", strongPassword)
}
}
Тепер можемо використовувати наш кастомний валідатор у DTO:
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,strongpwd"`
}
Реєстрація валідаторів має відбуватися один раз при ініціалізації застосунку:
func main() {
validator.RegisterCustomValidators()
r := gin.Default()
// ... налаштування маршрутів
r.Run(":8080")
}
Обробка багатокомпонентних запитів та завантаження файлів
Коли клієнт надсилає файли (наприклад, аватар користувача, документи, зображення), HTTP-запит використовує кодування multipart/form-data. Gin надає зручні методи для роботи з такими запитами.
Завантаження одного файлу
Розглянемо обробник завантаження аватара користувача:
package handler
import (
"fmt"
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type FileHandler struct {
uploadDir string
}
func NewFileHandler(uploadDir string) *FileHandler {
return &FileHandler{uploadDir: uploadDir}
}
// UploadAvatar обробляє завантаження файлу аватара
func (h *FileHandler) UploadAvatar(c *gin.Context) {
// Отримання файлу із запиту (ключ "avatar" у формі)
file, err := c.FormFile("avatar")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "No file uploaded",
})
return
}
// Валідація розміру файлу (максимум 5 МБ)
const maxFileSize = 5 * 1024 * 1024 // 5 MB
if file.Size > maxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": "File too large (max 5MB)",
})
return
}
// Валідація типу файлу за розширенням
ext := filepath.Ext(file.Filename)
allowedExts := map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
}
if !allowedExts[ext] {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid file type (allowed: jpg, jpeg, png, gif)",
})
return
}
// Генерація унікального імені файлу
newFilename := fmt.Sprintf("%s%s", uuid.New().String(), ext)
savePath := filepath.Join(h.uploadDir, newFilename)
// Збереження файлу на диск
if err := c.SaveUploadedFile(file, savePath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to save file",
})
return
}
// Успішна відповідь
c.JSON(http.StatusOK, gin.H{
"message": "File uploaded successfully",
"filename": newFilename,
"size": file.Size,
"url": fmt.Sprintf("/uploads/%s", newFilename),
})
}
Ключові моменти:
c.FormFile("avatar")отримує файл із поля форми з ім'ямavatar.file.Sizeмістить розмір файлу у байтах.c.SaveUploadedFile(file, path)зберігає файл на диск.- Обов'язково валідуйте розмір, тип та ім'я файлу для безпеки.
Завантаження декількох файлів
Для завантаження множини файлів використовуйте метод MultipartForm():
func (h *FileHandler) UploadMultiple(c *gin.Context) {
// Отримання форми з файлами
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Отримання масиву файлів (ключ "documents")
files := form.File["documents"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "No files uploaded",
})
return
}
uploadedFiles := make([]string, 0, len(files))
for _, file := range files {
// Валідація та збереження кожного файлу
filename := fmt.Sprintf("%s_%s", uuid.New().String(), file.Filename)
savePath := filepath.Join(h.uploadDir, filename)
if err := c.SaveUploadedFile(file, savePath); err != nil {
continue // Пропускаємо файли з помилками
}
uploadedFiles = append(uploadedFiles, filename)
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Uploaded %d files", len(uploadedFiles)),
"files": uploadedFiles,
})
}
Комбінування файлів та JSON-даних
Часто потрібно надіслати файл разом із структурованими даними (наприклад, створити профіль користувача з аватаром та JSON-даними). У цьому випадку клієнт надсилає дані як поля форми:
type CreateProfileRequest struct {
FullName string `form:"full_name" binding:"required"`
Bio string `form:"bio" binding:"omitempty,max=500"`
}
func (h *UserHandler) CreateProfile(c *gin.Context) {
// Зв'язування полів форми
var req CreateProfileRequest
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Отримання файлу
avatar, err := c.FormFile("avatar")
if err == nil {
// Обробка файлу, якщо він був наданий
// ... збереження файлу
}
// Створення профілю у базі даних
// ... бізнес-логіка
c.JSON(http.StatusCreated, gin.H{"message": "Profile created"})
}
- Завжди валідуйте MIME-тип файлу за допомогою
http.DetectContentType(), а не лише за розширенням. - Обмежуйте розмір файлу через
c.Request.Body = http.MaxBytesReader(...)або на рівні middleware. - Не зберігайте файли з оригінальними іменами — використовуйте UUID або hash.
- Не зберігайте файли у веб-корені — зберігайте їх поза публічною директорією та роздавайте через окремий обробник.
- Сканування антивірусом для критичних застосунків.
Автентифікація на основі JWT токенів
JWT (JSON Web Token) — це відкритий стандарт (RFC 7519) для створення токенів доступу, що містять JSON-об'єкт із claims (твердженнями) про користувача. Токени підписані криптографічно, що дозволяє серверу перевірити їх автентичність без звернення до бази даних.
Анатомія JWT токена
JWT складається з трьох частин, розділених крапками:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header (заголовок) — алгоритм підпису та тип токена:
{ "alg": "HS256", "typ": "JWT" } - Payload (корисне навантаження) — claims про користувача:
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022, "exp": 1516242622 } - Signature (підпис) — криптографічний підпис перших двох частин:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )
Встановлення бібліотеки JWT
Використовуємо бібліотеку golang-jwt/jwt:
go get -u github.com/golang-jwt/jwt/v5
Генерація JWT токенів
Створимо сервіс для роботи з JWT:
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims описує структуру даних у JWT токені
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type JWTService struct {
secretKey []byte
accessExpiry time.Duration
refreshExpiry time.Duration
}
func NewJWTService(secret string, accessExp, refreshExp time.Duration) *JWTService {
return &JWTService{
secretKey: []byte(secret),
accessExpiry: accessExp,
refreshExpiry: refreshExp,
}
}
// GenerateAccessToken створює короткостроковий токен доступу
func (s *JWTService) GenerateAccessToken(userID, email, role string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessExpiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "myapp-server",
Subject: userID,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secretKey)
}
// GenerateRefreshToken створює довгостроковий токен оновлення
func (s *JWTService) GenerateRefreshToken(userID string) (string, error) {
claims := jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.refreshExpiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: userID,
Issuer: "myapp-server",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secretKey)
}
// ValidateToken перевіряє та парсить JWT токен
func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Перевірка алгоритму підпису
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return s.secretKey, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
Типові терміни життя токенів:
- Access Token: 15 хвилин – 1 година (короткострокові, передаються з кожним запитом)
- Refresh Token: 7-30 днів (довгострокові, зберігаються на клієнті та використовуються для оновлення Access токенів)
Middleware автентифікації JWT
Створимо middleware, який перевірятиме наявність та валідність JWT у заголовку Authorization:
package middleware
import (
"net/http"
"strings"
"myapp/pkg/auth"
"github.com/gin-gonic/gin"
)
// AuthMiddleware перевіряє JWT токен у заголовку Authorization
func AuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
return func(c *gin.Context) {
// Отримання заголовка Authorization
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authorization header missing",
})
c.Abort()
return
}
// Очікується формат: "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Invalid authorization format (expected: Bearer <token>)",
})
c.Abort()
return
}
tokenString := parts[1]
// Валідація токена
claims, err := jwtService.ValidateToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Invalid or expired token",
"details": err.Error(),
})
c.Abort()
return
}
// Збереження даних користувача у контексті
c.Set("user_id", claims.UserID)
c.Set("user_email", claims.Email)
c.Set("user_role", claims.Role)
c.Next()
}
}
// RequireRole створює middleware для перевірки ролі користувача
func RequireRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, exists := c.Get("user_role")
if !exists {
c.JSON(http.StatusForbidden, gin.H{
"error": "Access denied: role information missing",
})
c.Abort()
return
}
roleStr := userRole.(string)
// Перевірка, чи має користувач одну з дозволених ролей
hasRole := false
for _, role := range roles {
if roleStr == role {
hasRole = true
break
}
}
if !hasRole {
c.JSON(http.StatusForbidden, gin.H{
"error": "Access denied: insufficient permissions",
})
c.Abort()
return
}
c.Next()
}
}
Використання JWT у маршрутах
Тепер можемо захистити маршрути за допомогою нашого middleware:
func setupRoutes(r *gin.Engine, jwtService *auth.JWTService, handlers *handler.Handlers) {
api := r.Group("/api/v1")
// Публічні маршрути (без автентифікації)
public := api.Group("/auth")
{
public.POST("/register", handlers.Auth.Register)
public.POST("/login", handlers.Auth.Login)
public.POST("/refresh", handlers.Auth.RefreshToken)
}
// Захищені маршрути (потрібна автентифікація)
protected := api.Group("")
protected.Use(middleware.AuthMiddleware(jwtService))
{
// Доступно всім автентифікованим користувачам
protected.GET("/profile", handlers.User.GetProfile)
protected.PUT("/profile", handlers.User.UpdateProfile)
// Доступно лише адміністраторам
admin := protected.Group("/admin")
admin.Use(middleware.RequireRole("admin", "superadmin"))
{
admin.GET("/users", handlers.Admin.ListUsers)
admin.DELETE("/users/:id", handlers.Admin.DeleteUser)
}
}
}
Реалізація потоку автентифікації (Login + Refresh)
Повний приклад обробника входу та оновлення токенів:
package handler
import (
"net/http"
"myapp/internal/dto"
"myapp/internal/usecase"
"myapp/pkg/auth"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
userUseCase usecase.UserUseCase
jwtService *auth.JWTService
}
func NewAuthHandler(uc usecase.UserUseCase, jwt *auth.JWTService) *AuthHandler {
return &AuthHandler{
userUseCase: uc,
jwtService: jwt,
}
}
// Login обробляє вхід користувача
func (h *AuthHandler) Login(c *gin.Context) {
var req dto.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Перевірка облікових даних
user, err := h.userUseCase.Authenticate(c.Request.Context(), req.Email, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Invalid credentials",
})
return
}
// Генерація токенів
accessToken, err := h.jwtService.GenerateAccessToken(user.ID, user.Email, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
refreshToken, err := h.jwtService.GenerateRefreshToken(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Відповідь із токенами
c.JSON(http.StatusOK, dto.AuthResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: 900, // 15 хвилин у секундах
})
}
// RefreshToken оновлює Access Token за допомогою Refresh Token
func (h *AuthHandler) RefreshToken(c *gin.Context) {
var req dto.RefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Валідація Refresh Token
claims, err := h.jwtService.ValidateToken(req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Invalid refresh token",
})
return
}
// Отримання актуальних даних користувача
user, err := h.userUseCase.GetByID(c.Request.Context(), claims.Subject)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "User not found",
})
return
}
// Генерація нового Access Token
newAccessToken, err := h.jwtService.GenerateAccessToken(user.ID, user.Email, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, dto.RefreshTokenResponse{
AccessToken: newAccessToken,
TokenType: "Bearer",
ExpiresIn: 900,
})
}
Access Token має короткий термін дії (15-60 хв) та передається з кожним запитом. Якщо токен буде перехоплено, зловмисник матиме обмежений час для атаки.
Refresh Token має тривалий термін дії (7-30 днів) та використовується лише для оновлення Access токенів. Зберігається на клієнті у безпечному сховищі (HttpOnly Cookie для веб або Keychain/Keystore для мобільних застосунків).
Це реалізує принцип least privilege — кожен токен має мінімальні привілеї та час життя, необхідні для своєї функції.
Не рекомендується для вебзастосунків. LocalStorage доступний для JavaScript-коду, що робить токени вразливими до атак XSS (Cross-Site Scripting).
Кращі альтернативи:
- HttpOnly Cookies — браузер автоматично надсилає cookie, але JavaScript не може до них звернутися.
- Memory storage — зберігання у змінних JavaScript (втрачається при оновленні сторінки, але безпечніше).
- Для мобільних застосунків: Secure Storage (iOS Keychain, Android Keystore).
Автоматична генерація документації Swagger / OpenAPI
OpenAPI (раніше відомий як Swagger) — це стандарт для опису REST API, який дозволяє автоматично генерувати інтерактивну документацію, клієнтські SDK та серверні заглушки (stubs).
Swagger UI — це вебінтерфейс, який читає специфікацію OpenAPI та надає інтерактивну документацію з можливістю тестування ендпоінтів прямо у браузері.
Встановлення Swag та Gin Swagger
Для автоматичної генерації документації використовуємо інструмент swag:
go install github.com/swaggo/swag/cmd/swag@latest
go get -u github.com/swaggo/gin-swagger
go get -u github.com/swaggo/files
Базова конфігурація Swagger у main.go
Додамо загальну інформацію про API у коментарях до функції main():
package main
import (
"myapp/docs" // Буде згенеровано командою swag init
"myapp/internal/handler"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// @title MyApp REST API
// @version 1.0
// @description Серверний API для застосунку MyApp із підтримкою автентифікації JWT
// @termsOfService http://myapp.example.com/terms/
// @contact.name API Support Team
// @contact.url http://myapp.example.com/support
// @contact.email support@myapp.example.com
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description JWT Authorization header using the Bearer scheme. Example: "Bearer {token}"
func main() {
r := gin.Default()
// Маршрут для Swagger UI
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// ... налаштування маршрутів
r.Run(":8080")
}
Пояснення анотацій:
@title— назва API@version— версія API@description— опис застосунку@host— адреса сервера@BasePath— базовий префікс всіх маршрутів@securityDefinitions.apikey— опис схеми автентифікації JWT
Документування ендпоінтів
Тепер додамо анотації до обробників:
package handler
import (
"net/http"
"myapp/internal/dto"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
// ... залежності
}
// GetProfile отримує профіль поточного користувача
// @Summary Отримати профіль користувача
// @Description Повертає детальну інформацію про профіль автентифікованого користувача
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} dto.UserProfileResponse
// @Failure 401 {object} dto.ErrorResponse "Unauthorized"
// @Failure 500 {object} dto.ErrorResponse "Internal Server Error"
// @Router /users/profile [get]
func (h *UserHandler) GetProfile(c *gin.Context) {
userID := c.GetString("user_id")
profile, err := h.userUseCase.GetProfile(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{
Error: "Failed to fetch profile",
})
return
}
c.JSON(http.StatusOK, profile)
}
// UpdateProfile оновлює профіль користувача
// @Summary Оновити профіль
// @Description Оновлює інформацію профілю автентифікованого користувача
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.UpdateProfileRequest true "Дані для оновлення"
// @Success 200 {object} dto.UserProfileResponse
// @Failure 400 {object} dto.ErrorResponse "Bad Request"
// @Failure 401 {object} dto.ErrorResponse "Unauthorized"
// @Failure 500 {object} dto.ErrorResponse "Internal Server Error"
// @Router /users/profile [put]
func (h *UserHandler) UpdateProfile(c *gin.Context) {
var req dto.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{
Error: "Validation failed",
Details: err.Error(),
})
return
}
userID := c.GetString("user_id")
profile, err := h.userUseCase.UpdateProfile(c.Request.Context(), userID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{
Error: "Failed to update profile",
})
return
}
c.JSON(http.StatusOK, profile)
}
// Register реєструє нового користувача
// @Summary Реєстрація користувача
// @Description Створює новий обліковий запис користувача
// @Tags Authentication
// @Accept json
// @Produce json
// @Param request body dto.RegisterRequest true "Дані реєстрації"
// @Success 201 {object} dto.AuthResponse
// @Failure 400 {object} dto.ErrorResponse "Bad Request"
// @Failure 409 {object} dto.ErrorResponse "Conflict - Email already exists"
// @Failure 500 {object} dto.ErrorResponse "Internal Server Error"
// @Router /auth/register [post]
func (h *AuthHandler) Register(c *gin.Context) {
// ... реалізація
}
Ключові анотації Swagger:
@Summary— короткий опис ендпоінту (відображається у списку)@Description— детальний опис функціональності@Tags— категорія ендпоінту для групування у UI@Accept— формат вхідних даних (json,xml,multipart/form-data)@Produce— формат відповіді@Security— схема автентифікації (BearerAuth з@securityDefinitions)@Param— опис параметрів (path, query, body, header, form)@Success— успішна відповідь (код статусу, тип об'єкта, опис)@Failure— помилки (код статусу, тип об'єкта, опис)@Router— шлях та HTTP-метод
Документування моделей даних (DTO)
Swagger автоматично аналізує структури Go та генерує схеми об'єктів. Для кращої документації додайте теги example:
package dto
// RegisterRequest описує дані для реєстрації користувача
type RegisterRequest struct {
Email string `json:"email" binding:"required,email" example:"user@example.com"`
Password string `json:"password" binding:"required,min=8,strongpwd" example:"MyPassword123"`
FullName string `json:"full_name" binding:"required,min=2,max=100" example:"Іван Петренко"`
Age int `json:"age" binding:"omitempty,gte=18,lte=120" example:"25"`
}
// UserProfileResponse повертає інформацію про профіль користувача
type UserProfileResponse struct {
ID string `json:"id" example:"550e8400-e29b-41d4-a716-446655440000"`
Email string `json:"email" example:"user@example.com"`
FullName string `json:"full_name" example:"Іван Петренко"`
Bio string `json:"bio" example:"Розробник програмного забезпечення"`
AvatarURL string `json:"avatar_url" example:"https://example.com/avatars/user123.jpg"`
CreatedAt string `json:"created_at" example:"2026-01-15T10:30:00Z"`
}
// ErrorResponse стандартний формат повідомлення про помилку
type ErrorResponse struct {
Error string `json:"error" example:"Validation failed"`
Details string `json:"details,omitempty" example:"field 'email' is required"`
}
// AuthResponse повертає токени після успішної автентифікації
type AuthResponse struct {
AccessToken string `json:"access_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
RefreshToken string `json:"refresh_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
TokenType string `json:"token_type" example:"Bearer"`
ExpiresIn int `json:"expires_in" example:"900"`
}
Генерація документації
Після додавання всіх анотацій, виконайте команду генерації:
swag init
Ця команда:
- Сканує всі файли Go у проєкті
- Аналізує анотації Swagger
- Генерує директорію
docs/із файламиdocs.go,swagger.jsonтаswagger.yaml
swag init потрібно запускати кожного разу після зміни анотацій Swagger у коді. Для автоматизації можна додати її до pre-commit хуку або CI/CD pipeline.Перегляд документації
Запустіть сервер та відкрийте у браузері:
http://localhost:8080/swagger/index.html
Ви побачите інтерактивний інтерфейс із переліком усіх ендпоінтів, згрупованих за тегами. Кожен ендпоінт можна розгорнути, переглянути параметри, моделі даних та виконати тестовий запит прямо з браузера.
Тестування ендпоінтів через Swagger UI
У Swagger UI можна:
- Розгорнути ендпоінт — клацнути на назву методу (GET, POST тощо)
- Переглянути параметри — побачити всі параметри, їх типи та приклади
- Натиснути "Try it out" — активувати режим редагування
- Ввести дані — змінити значення параметрів або JSON-тіла
- Додати авторизацію — натиснути кнопку "Authorize" та ввести JWT токен
- Виконати запит — натиснути "Execute"
- Переглянути відповідь — побачити статус-код, заголовки та тіло відповіді
- Postman може імпортувати файл
swagger.jsonта автоматично створити колекцію запитів. - Інструменти кодогенерації (OpenAPI Generator) можуть згенерувати клієнтські SDK для Java, C#, TypeScript, Python тощо.
- CI/CD pipeline може автоматично перевіряти актуальність документації порівнянням змін у коді та
swagger.json.
Практичні рекомендації та безпека
Валідація на різних рівнях
У промислових застосунках валідація здійснюється на кількох рівнях:
- Клієнтська валідація — миттєвий зворотний зв'язок для користувача (JavaScript у веб, нативна валідація у мобільних застосунках).
- HTTP-рівень (Gin Binding) — перевірка синтаксису, типів даних та базових обмежень через теги
binding. - Бізнес-рівень (UseCase) — перевірка бізнес-правил, які не можуть бути виражені простими тегами:
func (uc *UserUseCase) RegisterUser(ctx context.Context, req dto.RegisterRequest) error { // Перевірка унікальності email у базі даних exists, err := uc.repo.EmailExists(ctx, req.Email) if err != nil { return err } if exists { return errors.New("email already registered") } // Перевірка, чи не знаходиться email у чорному списку if uc.isBlacklisted(req.Email) { return errors.New("email domain not allowed") } // ... інші бізнес-правила }
Безпека JWT токенів
Обов'язкові практики:
- Використовуйте сильний секретний ключ:
// ❌ НІКОЛИ НЕ ВИКОРИСТОВУЙТЕ СЛАБКІ КЛЮЧІ secretKey := "secret" // ✅ ГЕНЕРУЙТЕ ВИПАДКОВИЙ КЛЮЧ (мінімум 256 біт) secretKey := os.Getenv("JWT_SECRET") // Зберігати у змінних оточення - Встановлюйте короткі терміни дії Access токенів:
accessExpiry := 15 * time.Minute // ✅ Рекомендовано accessExpiry := 7 * 24 * time.Hour // ❌ Надто довго - Перевіряйте алгоритм підпису:
func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { // Захист від атаки "алгоритм None" if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return s.secretKey, nil }) // ... } - Використовуйте HTTPS для передачі токенів:
- Токени передаються у відкритому вигляді (base64, не шифрування)
- HTTPS шифрує весь HTTP-трафік, включаючи заголовки
- Реалізуйте blacklist для відкликання токенів:
// При виході користувача додайте токен у чорний список (Redis) func (s *AuthService) Logout(ctx context.Context, tokenID string) error { // Зберегти tokenID у Redis до моменту його експірації return s.redis.Set(ctx, "blacklist:"+tokenID, "1", s.accessExpiry).Err() } // Перевірка у middleware func AuthMiddleware(jwtService *auth.JWTService, redis *redis.Client) gin.HandlerFunc { return func(c *gin.Context) { // ... парсинг токена // Перевірка blacklist exists, _ := redis.Exists(c, "blacklist:"+claims.ID).Result() if exists > 0 { c.JSON(http.StatusUnauthorized, gin.H{"error": "Token revoked"}) c.Abort() return } c.Next() } }
Обмеження розміру запитів
Gin за замовчуванням обмежує розмір тіла запиту до 32 МБ. Для роботи з файлами можна налаштувати ліміт:
func main() {
r := gin.Default()
// Обмеження розміру тіла запиту до 10 МБ
r.MaxMultipartMemory = 10 << 20 // 10 MB
// Для окремих маршрутів можна використати middleware
r.POST("/upload", limitRequestSize(5<<20), handlers.UploadFile)
r.Run(":8080")
}
func limitRequestSize(maxBytes int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
c.Next()
}
}
Стандартизація відповідей про помилки
Використовуйте єдиний формат помилок згідно зі стандартом RFC 7807 (Problem Details):
package dto
// ProblemDetails стандартний формат помилок RFC 7807
type ProblemDetails struct {
Type string `json:"type" example:"https://api.example.com/errors/validation-error"`
Title string `json:"title" example:"Validation Error"`
Status int `json:"status" example:"400"`
Detail string `json:"detail" example:"The 'email' field must be a valid email address"`
Instance string `json:"instance" example:"/api/v1/users/register"`
Errors map[string]interface{} `json:"errors,omitempty"`
}
Використання у middleware обробки помилок:
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// Перевірка, чи були помилки під час обробки
if len(c.Errors) > 0 {
err := c.Errors.Last()
problem := dto.ProblemDetails{
Type: "https://api.example.com/errors/internal-error",
Title: "Internal Server Error",
Status: http.StatusInternalServerError,
Detail: err.Error(),
Instance: c.Request.URL.Path,
}
c.JSON(problem.Status, problem)
}
}
}
Підсумки
На цій лекції ми розглянули ключові аспекти створення промислових REST API на фреймворку Gin, які виходять за межі базової маршрутизації:
Зв'язування та валідація даних:
- Механізм Model Binding автоматизує парсинг даних із різних джерел (JSON, Query, URI, Form)
- Декларативна валідація через теги
bindingзабезпечує перевірку даних без написання рутинного коду - Можливість створення власних валідаторів для специфічних бізнес-правил
Робота з файлами:
- Обробка одиночних та множинних завантажень через
FormFileтаMultipartForm - Валідація розміру, типу та імені файлів для безпеки
- Комбінування файлів зі структурованими даними у одному запиті
Автентифікація JWT:
- JWT токени надають безстанову (stateless) автентифікацію без звернення до бази даних на кожному запиті
- Патерн Access/Refresh токенів балансує між безпекою та зручністю
- Middleware автоматично перевіряє токени та забезпечує контроль доступу на основі ролей
Документація API:
- Swagger/OpenAPI автоматизує створення актуальної документації
- Інтерактивний інтерфейс дозволяє тестувати ендпоінти без зовнішніх інструментів
- Анотації у коді гарантують синхронізацію документації з реалізацією
Питання для самоперевірки
Методи Bind*:
- Автоматично встановлюють статус-код
400 Bad Requestпри помилці валідації - Автоматично визначають джерело даних за заголовком
Content-Type - Підходять для швидкої розробки, але дають менше контролю
Методи ShouldBind*:
- НЕ встановлюють статус-код автоматично — розробник повністю контролює обробку помилок
- Вимагають явного вказування джерела даних (
ShouldBindJSON,ShouldBindQueryтощо) - Рекомендовані для промислового коду, де потрібна стандартизована обробка помилок
Приклад різниці:
// Bind - автоматична обробка
if err := c.Bind(&req); err != nil {
// Gin вже встановив 400 Bad Request
return
}
// ShouldBind - ручний контроль
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ProblemDetails{
Type: "validation-error",
Title: "Validation Failed",
Detail: err.Error(),
})
return
}
Багаторівнева валідація реалізує принцип Defense in Depth (багатошарова оборона):
- Клієнтська валідація (Frontend):
- Миттєвий зворотний зв'язок для користувача
- Покращує UX (користувач бачить помилки відразу)
- НЕ є захистом (легко обійти через DevTools або прямі HTTP-запити)
- HTTP-рівень (Gin Binding):
- Перевіряє синтаксис, типи даних, базові обмеження
- Захищає від некоректних даних від будь-якого клієнта
- Швидка валідація без звернення до бази даних
- Бізнес-рівень (UseCase):
- Перевіряє складні бізнес-правила (унікальність email, перевірка квот, обмеження доступу)
- Може звертатися до бази даних та зовнішніх сервісів
- Містить логіку, специфічну для предметної області
Аналогія: Будинок із замком на дверях (клієнт), охороною на вході (HTTP) та сигналізацією всередині (бізнес-логіка). Кожен шар додає захист.
НІКОЛИ не зберігайте секретні ключі у коді:
// ❌ НЕБЕЗПЕЧНО
const jwtSecret = "my-super-secret-key"
Правильні підходи:
- Змінні оточення (.env файл):
# .env (додати до .gitignore!) JWT_SECRET=d8f4a7b2c9e1f6a3d5b8c2e7f1a4b9c6d3e8f2a7b5c9d1e6f4a8b3c7d2e9f5a1import "github.com/joho/godotenv" func main() { godotenv.Load() jwtSecret := os.Getenv("JWT_SECRET") if jwtSecret == "" { log.Fatal("JWT_SECRET not set") } } - Системи управління секретами:
- Docker Secrets (для Docker Swarm)
- Kubernetes Secrets (для K8s)
- HashiCorp Vault (промислові застосунки)
- AWS Secrets Manager / Azure Key Vault / Google Secret Manager (хмарні платформи)
- Генерація сильних ключів:
# Генерація випадкового ключа (256 біт) openssl rand -hex 32
Ротація ключів: У критичних системах ключі мають періодично оновлюватися. Це вимагає підтримки кількох ключів одночасно (старий для перевірки існуючих токенів, новий для генерації).
Без обмеження розміру файлів сервер стає вразливим до DoS-атак:
- Виснаження пам'яті:
- Зловмисник надсилає файл розміром 5 ГБ
- Сервер намагається завантажити його у пам'ять
- Процес аварійно завершується через
Out of Memory
- Виснаження дискового простору:
- Масове завантаження великих файлів заповнює диск
- Сервер втрачає можливість писати логи, зберігати дані
- Блокування мережі:
- Повільне завантаження великого файлу блокує горутину
- При обмеженій кількості одночасних з'єднань нові клієнти не можуть підключитися
Захист:
// Глобальне обмеження для всього застосунку
r := gin.Default()
r.MaxMultipartMemory = 10 << 20 // 10 MB
// Обмеження на рівні обробника
func (h *FileHandler) UploadFile(c *gin.Context) {
file, _ := c.FormFile("file")
const maxSize = 5 * 1024 * 1024 // 5 MB
if file.Size > maxSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": "File too large (max 5MB)",
})
return
}
// ...
}
Додатковий захист: Використовуйте зовнішні сервіси зберігання (AWS S3, Google Cloud Storage) із прямим завантаженням від клієнта через presigned URLs.
Refresh Token реалізує баланс між безпекою та зручністю:
Без Refresh Token (лише Access Token):
- Варіант А: Короткий термін дії (15 хв) → Користувач виходить кожні 15 хвилин → Погана UX
- Варіант Б: Довгий термін дії (30 днів) → Якщо токен вкрадено, зловмисник має доступ 30 днів → Погана безпека
З Refresh Token:
- Access Token живе 15 хвилин → Якщо вкрадено, обмежений час для атаки
- Refresh Token живе 30 днів → Зберігається безпечно на клієнті, використовується рідко
- Клієнт автоматично оновлює Access Token через Refresh Token → Користувач не помічає
Додаткові переваги:
- Відкликання сесій: Можна інвалідувати Refresh Token у базі даних (logout на всіх пристроях)
- Моніторинг: Оновлення токенів логуються → можна виявити підозрілу активність
- Гнучкість: Можна змінити права доступу користувача, і при наступному оновленні він отримає новий Access Token із оновленими claims
Додаткові матеріали
📚 Документація бібліотек
- Gin Framework — офіційна документація
- go-playground/validator — правила валідації
- golang-jwt/jwt — робота з JWT
- Swaggo — генерація Swagger документації
🔐 Стандарти безпеки
- RFC 7519 - JSON Web Token (JWT)
- RFC 7807 - Problem Details for HTTP APIs
- OWASP Top 10 — найпоширеніші вразливості веб-застосунків
- Реєстрація/вхід користувачів із JWT автентифікацією
- CRUD операції для завдань (лише власні завдання користувача)
- Завантаження файлів-вкладень до завдань
- Валідація даних через теги
binding - Повна Swagger документація
- Роль "admin" може переглядати всі завдання
Лекція 11. Архітектура та маршрутизація фреймворку Gin
Знайомство з вебфреймворком Gin: архітектура, двигун Engine, контекст запиту, маршрутизація та middleware
Лекція 13. Взаємодія з реляційними СУБД та технології ORM
Підключення до PostgreSQL, робота з чистим SQL через pgx, транзакції, міграції та об