Тест на вакансию

Создание REST API приложения на Go с авторизацией по токену

19 сентября 2023 г.
188

Установка Go

Загружаем и устанавливаем Go с официального сайта.

Создаём новое приложение:
mkdir go-api-app
cd go-api-app

Установка зависимостей

go mod init go-api-app
go get github.com/gin-gonic/gin
go get github.com/jinzhu/gorm
go get gorm.io/driver/postgres
go get github.com/dgrijalva/jwt-go
go get github.com/joho/godotenv
go get golang.org/x/crypto/bcrypt
go get golang.org/x/sync/semaphore

Файл .env

Создадим файл в корне:
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=go_api_db
JWT_SECRET=your-super-secret-jwt-key-here
SERVER_PORT=3000

Конфигурация (config/config.go)

package config

import (
    "os"
    "strconv"
    "github.com/joho/godotenv"
)

type Config struct {
    DBHost     string
    DBPort     int
    DBUser     string
    DBPassword string
    DBName     string
    JWTSecret  string
    ServerPort string
}

func LoadConfig() (*Config, error) {
    err := godotenv.Load()
    if err != nil {
        return nil, err
    }
    dbPort, _ := strconv.Atoi(os.Getenv("DB_PORT"))
    return &Config{
        DBHost:     os.Getenv("DB_HOST"),
        DBPort:     dbPort,
        DBUser:     os.Getenv("DB_USER"),
        DBPassword: os.Getenv("DB_PASSWORD"),
        DBName:     os.Getenv("DB_NAME"),
        JWTSecret:  os.Getenv("JWT_SECRET"),
        ServerPort: os.Getenv("SERVER_PORT"),
    }, nil
}

Модели данных

Создадим и отредактируем файл (models/user.go):
package models

import (
    "time"
    "golang.org/x/crypto/bcrypt"
    "gorm.io/gorm"
)

type User struct {
    ID        uint           `json:"id" gorm:"primaryKey"`
    Username  string         `json:"username" gorm:"unique;not null"`
    Email     string         `json:"email" gorm:"unique;not null"`
    Password  string         `json:"-" gorm:"not null"`
    Role      string         `json:"role" gorm:"default:'user'"`
    CreatedAt time.Time      `json:"created_at"`
    UpdatedAt time.Time      `json:"updated_at"`
    DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}

func (u *User) HashPassword(password string) error {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
    if err != nil {
        return err
    }
    u.Password = string(bytes)
    return nil
}

func (u *User) CheckPassword(password string) error {
    return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
}
Создадим и отредактируем файл (models/news.go):
package models

import (
    "time"
    "gorm.io/gorm"
)

type News struct {
    ID        uint           `json:"id" gorm:"primaryKey"`
    Title     string         `json:"title" gorm:"not null"`
    Content   string         `json:"content" gorm:"type:text;not null"`
    Author    string         `json:"author" gorm:"not null"`
    Category  string         `json:"category"`
    ImageURL  string         `json:"image_url"`
    Published bool           `json:"published" gorm:"default:false"`
    CreatedAt time.Time      `json:"created_at"`
    UpdatedAt time.Time      `json:"updated_at"`
    DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}

type NewsResponse struct {
    ID        uint      `json:"id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    Author    string    `json:"author"`
    Category  string    `json:"category"`
    ImageURL  string    `json:"image_url"`
    Published bool      `json:"published"`
    CreatedAt time.Time `json:"created_at"`
}

JWT утилиты (utils/jwt.go)

Создадим и отредактируем файл:
package utils

import (
    "go-api-app/config"
    "time"
    "github.com/dgrijalva/jwt-go"
)

type Claims struct {
    UserID   uint   `json:"user_id"`
    Role     string `json:"role"`
    Username string `json:"username"`
    jwt.StandardClaims
}

func GenerateJWT(userID uint, role string, username string) (string, error) {
    cfg, err := config.LoadConfig()
    if err != nil {
        return "", err
    }
    expirationTime := time.Now().Add(24 * time.Hour)
    claims := &Claims{
        UserID:   userID,
        Role:     role,
        Username: username,
        StandardClaims: jwt.StandardClaims{
            ExpiresAt: expirationTime.Unix(),
            IssuedAt:  time.Now().Unix(),
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(cfg.JWTSecret))
}

func VerifyJWT(tokenString string) (*Claims, error) {
    cfg, err := config.LoadConfig()
    if err != nil {
        return nil, err
    }
    claims := &Claims{}
    token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
        return []byte(cfg.JWTSecret), nil
    })
    if err != nil {
        return nil, err
    }
    if !token.Valid {
        return nil, jwt.ErrSignatureInvalid
    }
    return claims, nil
}

База данных (database/db.go)

Создадим и отредактируем файл:
package database

import (
    "fmt"
    "go-api-app/config"
    "go-api-app/models"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

var DB *gorm.DB

func InitDB() error {
    cfg, err := config.LoadConfig()
    if err != nil {
        return err
    }
    dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
        cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName)
    DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        return err
    }
    // Автомиграция
    err = DB.AutoMigrate(&models.User{}, &models.News{})
    if err != nil {
        return err
    }
    fmt.Println("Database connected successfully")
    return nil
}

Middleware аутентификации (middleware/auth.go)

Создадим и отредактируем файл:
package middleware

import (
    "go-api-app/database"
    "go-api-app/models"
    "go-api-app/utils"
    "net/http"
    "strings"
    "github.com/gin-gonic/gin"
)

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
            c.Abort()
            return
        }
        tokenString := strings.Replace(authHeader, "Bearer ", "", 1)
        if tokenString == authHeader {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "Bearer token required"})
            c.Abort()
            return
        }
        claims, err := utils.VerifyJWT(tokenString)
        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
            c.Abort()
            return
        }
        var user models.User
        if err := database.DB.First(&user, claims.UserID).Error; err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
            c.Abort()
            return
        }
        c.Set("user_id", claims.UserID)
        c.Set("user_role", claims.Role)
        c.Set("username", user.Username)
        c.Next()
    }
}

func AdminMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        role, exists := c.Get("user_role")
        if !exists || role != "admin" {
            c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
            c.Abort()
            return
        }
        c.Next()
    }
}

Обработчики

Создадим и отредактируем файл (handlers/auth.go):
package handlers

import (
    "go-api-app/database"
    "go-api-app/models"
    "go-api-app/utils"
    "net/http"
    "github.com/gin-gonic/gin"
)

type LoginRequest struct {
    Username string `json:"username" binding:"required"`
    Password string `json:"password" binding:"required"`
}

type RegisterRequest struct {
    Username string `json:"username" binding:"required"`
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=6"`
}

func Register(c *gin.Context) {
    var req RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    var existingUser models.User
    if err := database.DB.Where("username = ? OR email = ?", req.Username, req.Email).First(&existingUser).Error; err == nil {
        c.JSON(http.StatusConflict, gin.H{"error": "Username or email already exists"})
        return
    }
    user := models.User{
        Username: req.Username,
        Email:    req.Email,
    }
    if err := user.HashPassword(req.Password); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not hash password"})
        return
    }
    if err := database.DB.Create(&user).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create user"})
        return
    }
    token, err := utils.GenerateJWT(user.ID, user.Role, user.Username)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not generate token"})
        return
    }
    c.JSON(http.StatusCreated, gin.H{
        "token": token,
        "user": gin.H{
            "id":       user.ID,
            "username": user.Username,
            "email":    user.Email,
            "role":     user.Role,
        },
    })
}

func Login(c *gin.Context) {
    var req LoginRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    var user models.User
    if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
        return
    }
    if err := user.CheckPassword(req.Password); err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
        return
    }
    token, err := utils.GenerateJWT(user.ID, user.Role, user.Username)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not generate token"})
        return
    }
    c.JSON(http.StatusOK, gin.H{
        "token": token,
        "user": gin.H{
            "id":       user.ID,
            "username": user.Username,
            "email":    user.Email,
            "role":     user.Role,
        },
    })
}
Создадим и отредактируем файл (handlers/news.go):
package handlers

import (
    "go-api-app/database"
    "go-api-app/models"
    "go-api-app/utils"
    "net/http"
    "strings"
    "github.com/gin-gonic/gin"
    "gorm.io/gorm"
)

type CreateNewsRequest struct {
    Title     string `json:"title" binding:"required"`
    Content   string `json:"content" binding:"required"`
    Category  string `json:"category"`
    ImageURL  string `json:"image_url"`
    Published bool   `json:"published"`
}

func GetNews(c *gin.Context) {
    var news []models.News
    query := database.DB
    authHeader := c.GetHeader("Authorization")
    if authHeader == "" {
        query = query.Where("published = ?", true)
    } else {
        tokenString := strings.Replace(authHeader, "Bearer ", "", 1)
        if claims, err := utils.VerifyJWT(tokenString); err == nil {
            var user models.User
            if database.DB.First(&user, claims.UserID).Error == nil {
                if user.Role == "admin" {
                } else {
                    query = query.Where("published = ? OR author = ?", true, user.Username)
                }
            } else {
                query = query.Where("published = ?", true)
            }
        } else {
            query = query.Where("published = ?", true)
        }
    }
    if category := c.Query("category"); category != "" {
        query = query.Where("category = ?", category)
    }
    if err := query.Order("created_at desc").Find(&news).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not fetch news"})
        return
    }
    c.JSON(http.StatusOK, news)
}

func GetNewsByID(c *gin.Context) {
    id := c.Param("id")
    var news models.News
    if err := database.DB.First(&news, id).Error; err != nil {
        if err == gorm.ErrRecordNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "News not found"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not fetch news"})
        return
    }
    if news.Published {
        c.JSON(http.StatusOK, news)
        return
    }
    authHeader := c.GetHeader("Authorization")
    if authHeader == "" {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required to view unpublished news"})
        return
    }
    tokenString := strings.Replace(authHeader, "Bearer ", "", 1)
    if tokenString == authHeader {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Bearer token required"})
        return
    }
    claims, err := utils.VerifyJWT(tokenString)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
        return
    }
    var user models.User
    if err := database.DB.First(&user, claims.UserID).Error; err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
        return
    }
    if user.Role == "admin" {
        c.JSON(http.StatusOK, news)
        return
    }
    if user.Username == news.Author {
        c.JSON(http.StatusOK, news)
        return
    }
    c.JSON(http.StatusForbidden, gin.H{"error": "Access denied. You can only view your own unpublished news"})
}

func CreateNews(c *gin.Context) {
    var req CreateNewsRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    usernameInterface, exists := c.Get("username")
    if !exists {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "User information not found"})
        return
    }
    username, ok := usernameInterface.(string)
    if !ok {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user information"})
        return
    }
    news := models.News{
        Title:     req.Title,
        Content:   req.Content,
        Author:    username,
        Category:  req.Category,
        ImageURL:  req.ImageURL,
        Published: req.Published,
    }
    if err := database.DB.Create(&news).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create news"})
        return
    }
    c.JSON(http.StatusCreated, news)
}

func UpdateNews(c *gin.Context) {
    id := c.Param("id")
    var news models.News
    if err := database.DB.First(&news, id).Error; err != nil {
        if err == gorm.ErrRecordNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "News not found"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not fetch news"})
        return
    }
    userRole, _ := c.Get("user_role")
    username, _ := c.Get("username")
    if userRole != "admin" && username != news.Author {
        c.JSON(http.StatusForbidden, gin.H{"error": "You can only edit your own news"})
        return
    }
    var req map[string]interface{}
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    if title, exists := req["title"]; exists {
        news.Title = title.(string)
    }
    if content, exists := req["content"]; exists {
        news.Content = content.(string)
    }
    if category, exists := req["category"]; exists {
        news.Category = category.(string)
    }
    if imageURL, exists := req["image_url"]; exists {
        news.ImageURL = imageURL.(string)
    }
    if published, exists := req["published"]; exists {
        news.Published = published.(bool)
    }
    if err := database.DB.Save(&news).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update news"})
        return
    }
    c.JSON(http.StatusOK, news)
}

func DeleteNews(c *gin.Context) {
    id := c.Param("id")
    var news models.News
    if err := database.DB.First(&news, id).Error; err != nil {
        if err == gorm.ErrRecordNotFound {
            c.JSON(http.StatusNotFound, gin.H{"error": "News! not found"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not fetch news"})
        return
    }
    userRole, roleExists := c.Get("user_role")
    username, usernameExists := c.Get("username")
    if !roleExists || !usernameExists {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "User information not available"})
        return
    }
    if userRole != "admin" {
        if username != news.Author {
            c.JSON(http.StatusForbidden, gin.H{"error": "You can only delete your own news"})
            return
        }
    }
    if err := database.DB.Delete(&news).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not delete news"})
        return
    }
    c.JSON(http.StatusOK, gin.H{"message": "News deleted successfully"})
}

Основной файл (main.go)

Создадим финальный файл:
package main

import (
    "go-api-app/config"
    "go-api-app/database"
    "go-api-app/handlers"
    "go-api-app/middleware"
    "log"
    "github.com/gin-gonic/gin"
)

func main() {
    cfg, err := config.LoadConfig()
    if err != nil {
        log.Fatal("Could not load config:", err)
    }

    if err := database.InitDB(); err != nil {
        log.Fatal("Could not connect to database:", err)
    }

    r := gin.Default()

    public := r.Group("/api")
    {
        public.POST("/register", handlers.Register)
        public.POST("/login", handlers.Login)
        public.GET("/news", handlers.GetNews)
        public.GET("/news/:id", handlers.GetNewsByID)
    }

    protected := r.Group("/api")
    protected.Use(middleware.AuthMiddleware())
    {
        protected.POST("/news", handlers.CreateNews)
        protected.PUT("/news/:id", handlers.UpdateNews)
        protected.DELETE("/news/:id", handlers.DeleteNews)
    }

    log.Printf("Server starting on port %s", cfg.ServerPort)
    if err := r.Run(":" + cfg.ServerPort); err != nil {
        log.Fatal("Could not start server:", err)
    }
}

Использование API

Запускаем наш API:
go run main.go
Сервер запуститься по адресу http://127.0.0.1:3000.

Регистрация нового пользователя:
curl -X POST http://localhost:3000/api/register \
  -H "Content-Type: application/json" \
  -d '{"username":"user1","email":"user1@test.ru","password":"password123"}'
Логин:
curl -X POST http://localhost:3000/api/login \
  -H "Content-Type: application/json" \
  -d '{"username":"user1","password":"password123"}'
Получение списка всех новостей (видно все новости со статусом published=true):
curl http://localhost:3000/api/news
Получение списка всех новостей (видно все новости автора):

curl http://localhost:3000/api/news/2 \
  -H "Authorization: Bearer $TOKEN"
С фильтрацией по категории:
curl "http://localhost:3000/api/news?category=технологии"
Получить новость по ID:
curl http://localhost:3000/api/news/1

С авторизацией (чтобы видеть неопубликованные):
curl http://localhost:3000/api/news/1 \
  -H "Authorization: Bearer $TOKEN"
Создание новости:
curl -X POST http://localhost:3000/api/news \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -d '{"title":"Новая новость","content":"Текст новости","category":"политика","published":true}'
Обновление новости:
curl -X PUT http://localhost:3000/api/news/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "title": "Обновленная новость",
    "content": "Это обновленное содержание новости",
    "category": "наука",
    "image_url": "https://www.jas.ru/images/updated-image.jpg",
    "published": false
  }'
Обновление только отдельных полей новости:
curl -X PUT http://localhost:3000/api/news/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "title": "Только заголовок изменен",
    "published": true
  }'
Удаление новости (с ID 1): 
curl -X DELETE http://localhost:3000/api/news/1 \
  -H "Authorization: Bearer $TOKEN"
Успешный ответ: {"message":"News deleted successfully"}

Сборка приложения

go build main.go

Готово!

Разработанное приложение представляет собой полнофункциональный REST API сервис для управления новостями с системой аутентификации и авторизации. API предоставляет гибкую систему прав доступа: анонимные пользователи могут просматривать только опубликованные новости, авторизованные пользователи получают доступ к своим неопубликованным материалам, а администраторы имеют полный контроль над всем контентом.

Приложение реализовано на современном стеке технологий Go с использованием фреймворка Gin, ORM Gorm для работы с базой данных и JWT токенов для безопасной аутентификации. API поддерживает все CRUD операции, фильтрацию по категориям и обеспечивает надежную защиту данных через проверку прав доступа на уровне каждого endpoint.

Поделиться: