Пишем gRPC сервис на Go — сервис авторизации


В этой статье мы научимся писать полноценный gRPC сервис на Go на примере сервера авторизации с полноценной архитектурой, готовой к продакшену. Мы напишем как серверную часть, так и клиентскую. В качестве клиента мы возьмём мой сервис — URL Shortener, о котором у меня также есть статья и видео-гайд на ютубе. Попутно мы познакомимся с базовыми подходами к работе с авторизацией. И в конце настроим автоматический деплой сервиса с помощью GitHub Actions на удалённый сервер.


Видео-версия этого гайда с более подробными объяснениями

Исходный код проекта: https://github.com/GolangLessons/sso


Итого, наш план:


  • Напишем простой, но полноценный gRPC-сервис
  • Разберемся с базовыми принципами работы авторизации — чтобы не было скучно
  • Настроим автоматический деплой в прод — потому что руками деплоить лень
  • Подружим его с уже готовым сервисом URL Shortener — чтобы был практический смысл
  • Напишем полноценные функциональные тесты

На выходе мы получим полноценный рабочий сервис авторизации, который вы сможете по аналогии подключать к своим пет-проектам.


Кратко обо мне: меня зовут Николай Тузов, я много лет занимаюсь разработкой на Go, очень люблю этот язык. Также веду свой YouTube-канал.


Используйте навигацию, если нет времени читать текст целиком:


Архитектура
Контракт Protobuf
Точка входа и конфигурация
gRPC сервер и обработка запросов
Сервисный слой — Auth
Слой работы с данными
Собираем компоненты приложения воедино
Функциональные тесты
Интеграция с внешним сервисом
Настраиваем автоматический деплой — GitHub Actions
Заключение


Важные вступительные оговорки


Статья получилась огромная, поэтому в ней наверняка есть ошибки, опечатки и неточности. Буду очень благодарен, если вы на них укажете в процессе чтения. Я постараюсь всё это исправлять по мере возможностей.


Я буду стараться писать код как на работе, с полноценной архитектурой сервиса, всё по-взрослому. Таким образом, результат будет выглядеть примерно как мои боевые сервисы. Но, будучи ограниченным форматом статьи, я всё же вынужден местами срезать углы и отсекать лишнее — иначе это была бы не статья, а целая книга. Но я буду стараться указывать вам на такие места, и буду давать советы по их дальнейшей самостоятельной доработке.


Подчеркиваю важный момент — эта статья, в первую очередь, про написание gRPC-сервиса, всё остальное — побочное. Мы не пишем полноценный Auth, мы не обсуждаем лучшие подходы к архитектуре, это лишь побочные бонусы. Если вы хотите полноценно погрузиться и в эти темы тоже, советую далее изучить более тематические статьи, книги и пр.


Комментарии в коде будут двух типов:


  • на русском: для читателей статьи
  • на английском: для читателей кода

То есть, английские комментарии — это часть моей программы (godoc и прочие), они будут также и в репозитории. Русские же комментарии — пояснения для читателей статьи, их не будет в репозитории.


SSO или Auth?


Обычно термином Auth называют сервисы, которые отвечают только за авторизацию и аутентификацию, а SSO нечто более общее — работа с правами (permissions), предоставление информации о пользователе и др.


Конечно, у подобных типов сервисов есть и более строгие определения, но когда я встречал эти сервисы на практике, границы всегда были размыты или вовсе стирались.


Чтобы внести ясность, термином SSO я называю сервис, объединяющий в себе три важных функции:


  • Авторизация (Auth)
  • Работа с пермишеннами (Permissions)
  • Предоставление информации о пользователе (User Info)

В этой статье будет только авторизация, но я планирую развивать этот сервис дальше в будущих статьях / роликах, поэтому буду планировать именно как SSO. Если не хотите пропустить продолжение, то советую подписаться на мой Telegram-канал, т.к. контент я публикую на разных площадках, а общую информацию обо всех активностях пишу только в нём.


Кроме того, вы можете дописать часть функционала SSO самостоятельно, т.к. после прочтения статьи у вас точно будут все необходимые знания и навыки, если осилите до конца.



Архитектура


Построение gRPC-сервиса и сервера авторизации (SSO) — это довольно сложные и объёмные темы. Поэтому, как я писал выше, я буду вынужден срезать углы, чтобы не растягивать статью до формата полноценной книги. Но я буду давать советы, следуя которым вы сможете уже самостоятельно прокачать ваши сервисы (как сервер, так и клиент). Это также будет хорошим упражнением – помните, что в обучении важнее всего практика!


Основные моменты, которыми будем жертвовать:


  • Архитектура сервиса. Сама архитектура в целом будет максимально приближена к боевому сервису. Жертвовать же будем в основном обвязками: мониторинг, lifecycle, тестами и т.п. (оно будет, но по минимуму)
  • Схема авторизации. А вот она будет максимально упрощена, т.к. основной фокус мы делаем на написание gRPC-сервиса. Но тем не менее, его функционала хватит для обслуживания ваших пет-проектов, а для дальнейшего развития я покажу направления и дам советы

Общая схема проекта — как будет работать авторизация


Действующие лица:


  • Пользователь (User) — человек, который вынужден авторизовываться, т.к. он хочет воспользоваться нашим URL Shortener'ом
  • URL Shortener — сервис, который будет клиентом SSO
  • Сервер авторизации (SSO) — сервис, который умеет авторизовывать, предоставлять информацию о правах пользователей и т.п.

Как это будет работать:


  • User (или используемое им приложение) отправляет запрос в SSO, чтобы получить JWT токен авторизации
  • С этим токеном он идёт в URL Shortener, чтобы выполнять разные полезные запросы — создавать короткие ссылки, удалять их и т.п.
  • URL Shortener получает запрос от клиента, достаёт из него токен, по которому понимает кто пришел, и что ему разрешено делать

Схема, взаимодействия пользователь (Client), SSO и другого сервиса
Схема, взаимодействия пользователь (Client), SSO и другого сервиса


Важные вещи, которых у нас не будет:


  • Проверка актуальности JWT — мы будем верить информации, которая в нём содержится. Мы можем так делать, т.к. JWT подписывается секретным ключом и подделать его не получится (об этом ниже)
  • Отзыв авторизации — поскольку мы верим информации в JWT, мы не сможем разлогинить пользователя до "протухания" его токена. Это сильно усложнило бы статью, ведь нам бы понадобилось хранить сессии в SSO, делать проверочные запросы от URL Shortener после получения токена и др.
  • Гибкая система ролей и пермишенов — это тема для отдельной большой статьи / видео

Почему мы можем верить информации внутри JWT? Ответ заключается в его внутреннем устройстве. Я приведу лишь краткий ликбез об этом, но советую почитать тематические статьи.


Краткий ликбез по JWT


JWT — это формат токена, состоящий из заголовка, payload и подписи.

В закодированном виде он выглядит вот так:


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBfaWQiOjEsImVtYWlsIjoiYXNkYWRzQGFzZC5jb20iLCJleHAiOjE2OTY5NTExMTgsInVpZCI6NjR9.b58TXBm1NKnVw0FvWyb5KFkG35WdB7JXeCiMWu8rNw0

А в декодированном так:


JWT токен в декодированном виде


Информация при этом НЕ зашифрована, и любой желающий без проблем может туда заглянуть. Но вот подделать payload (например, чтобы выдать себя за другого пользователя) не получится, т.к. подпись формируется из содержимого заголовка и payload. Соответственно, если мы меняем payload, то и подпись тоже должна измениться, иначе токен не пройдет валидацию.


Единственный способ подделать токен — украсть у нас приватный (секретный) ключ. Этот ключ будет храниться на сервере авторизации, и на клиентском сервисе (т.е. URL Shortener). Этот ключ нужно беречь как зеницу ока. Он используется как для формирования токена, так и для его валидации.


Если хотите поглубже погрузиться в эту тему, советую сайт jwt.io — там есть очень удобный encoder / decoder, а также полезные материалы, ссылки и т.п.



Приступаем к разработке: описание контракта и генерация кода


С чего начинается разработка любого gRPC сервиса? Правильно, с написания proto-файла (Protocol Buffers, protobuf). Если вы не знакомы с этим форматом, то не пугайтесь, это просто описание API (контракта) нашего сервиса. Ближайшая аналогия из мира REST API — Swagger / OpenAPI.


Описание контракта, это, в некотором смысле, API-документация к нашему сервису, которая обязана всегда быть актуальной, и по которой можно сгенерировать и клиент, и сервер. Это очень удобно, потому что нам не нужно поддерживать где-то отдельно документацию, и мы никогда не забудем обновить контракт, т.к. сервер напрямую с ним связан благодаря кодогенерации.


Хранить proto-файлы и сгенерированный код мы будем в отдельном репозитории, поскольку контракт нужен и серверу, и всем клиентам (сервисам, которые пользуются SSO). Сервисы будут подключать этот репозиторий через go.mod.


Итак, создаём проект с названием protos (либо contract / api-contracts или как вам больше нравится).


В корне проекта у меня будет две основных папки:


  • proto — тут будем хранить сами proto-файлы
  • gen — а здесь будет сгенерированный по ним код

В папке proto/sso создаём файл sso.proto. Его формат очень прост, в нём будут описаны:


  • Общая информация: версия протокола, пакет и опции для генерации go-файлов
  • Сервисы: по сути, это аналогии интерфейсам в Go — описание сигнатур методов, которые сервис должен реализовать
  • Формат сообщений: объекты, которые методы сервисов будут принимать и возвращать

Внутренний сервис у нас пока будет один: Auth (в будущем я планирую рядом добавить Permissions и UserInfo).


// proto/sso/sso.proto

// Версия ProtoBuf
syntax = "proto3";

// Текущий пакет - указывает пространство имен для сервиса и сообщений. Помогает избегать конфликтов имен.
package auth;

// Настройки для генерации Go кода.
option go_package = "tuzov.sso.v1;ssov1";

// Auth is service for managing permissions and roles.
service Auth {
  // Register registers a new user.
  rpc Register (RegisterRequest) returns (RegisterResponse);
  // Login logs in a user and returns an auth token.
  rpc Login (LoginRequest) returns (LoginResponse);
}

// TODO: На будущее, следующий сервис можно описать прямо здесь,
// либо вынести в отдельный файл
// service Permissions {
//    GetUserPermissions(GetUserPermissionsRequest) return UserPermissions
// }

// Объект, который отправляется при вызове RPC-метода (ручки) Register.
message RegisterRequest {
  string email = 1; // Email of the user to register.
  string password = 2; // Password of the user to register.
}

// Объект, который метод (ручка) вернёт.
message RegisterResponse {
  int64 user_id = 1; // User ID of the registered user.
}

// То же самое для метода Login()
message LoginRequest {
  string email = 1; // Email of the user to login.
  string password = 2; // Password of the user to login.
  int32 app_id = 3; // ID of the app to login to.
}

message LoginResponse {
  string token = 1; // Auth token of the logged in user.
}

Теперь по готовому контракту нам нужно сгенерировать Go-код. Для этого используется официальная утилита — protoc (компилятор Protocol Buffers). Для начала, вам её нужно установить, подробности по установке тут. Внимательно читайте инструкцию — нужно установить не только утилиту, но и плагин для Go.


Сначала создадим папку, в которой будем хранить сгенерированные файлы:


mkdir -p gen/go

Команда для генерации у нас будет выглядеть следующим образом:


protoc -I proto proto/sso/sso.proto --go_out=./gen/go/ --go_opt=paths=source_relative --go-grpc_out=./gen/go/ --go-grpc_opt=paths=source_relative

Давайте её разберём:


  • -I proto: Опция -I или --proto_path указывает путь к корневой директории с .proto файлами. Это нужно для того, чтобы компилятор смог найти импорты, если они есть. В нашем случае это директория proto.
  • proto/sso/sso.proto: путь к конкретному .proto файлу, который мы компилируем.
  • --go_out=./gen/go/: Опция --go_out указывает, куда записывать сгенерированный Go-код. В нашем случае — ./gen/go/.
  • --go_opt=paths=source_relative: дополнительная опция — указывает, как создавать имена пакетов. paths=source_relative означает, что выходные файлы будут иметь тот же пакет, что и исходные .proto файлы.
  • --go-grpc_out=./gen/go/: куда записывать сгенерированный Go gRPC-код. Как и в предыдущем случае, выходные файлы будут помещены в директорию ./gen/go/.
  • --go-grpc_opt=paths=source_relative: Это аналогичная опция для генерации Go gRPC-кода, указывающая, как создавать имена пакетов для gRPC.

Вместо пути до конкретного proto-файла можете использовать вот такую запись:


protoc -I proto proto/sso/*.proto <прочие параметры>

Тогда будет сгенерирован код по всем proto-файлам из указанной директории (не сработает для родной командной строки Windows).


Рекуррентно заходить в поддиректории оно, конечно, в таком виде не будет — это сделать немного сложнее. При необходимости, можете доработать скрипт самостоятельно, либо просто выполнять команду для каждой директории отдельно.


Из описания команды мы видим, что будут сгенерированы два типа файлов:


  • Go код: это набор типов данных и методов для работы с нашими protobuf сообщениями из программы на Go. Этот код позволяет создавать, манипулировать и сериализовать/десериализовать экземпляры сообщений.
  • Go gRPC код содержит клиентскую и серверную часть:
    • Серверная часть: это определения интерфейсов gRPC сервисов, включая методы RPC, которые должны быть реализованы. Также тут будут базовые реализации интерфейсов сервера, которые мы должны дополнить своей бизнес-логикой. Они предоставляют "скелет" сервера.
    • Клиентская часть: готовые клиенты для обращения к gRPC серверу.

Понимаю, что по началу всё это может выглядеть очень сложно, но всё станет намного понятней, когда мы начнем это использовать. Просто наберитесь немного терпения.


Для удобства рекомендую добавить вызов этой команды в Makefile, либо в Taskfile (аналог Make, но формат файлов более удобный). На текущей работе мы используем Task, он мне нравится и я к нему привык, поэтому покажу на его примере.


Для начала необходимо установить утилиту Task.


По аналогии с Makefile, утилита Task использует Taskfile, но формате yaml. Давайте напишем его:


# ./Taskfile.yaml
# See: https://taskfile.dev/api/  

version: "3"  

tasks:  
  default: # Если не указать конкретную команду, будут выполнены дефолтные
    cmds:  
      - task: generate  
  generate:  ## Команда для генерации
    aliases: ## Алиасы команды, для простоты использования
      - gen  
    desc: "Generate code from proto files"  
    cmds:  ## Тут описываем необходимые bash-команды
      - protoc -I proto proto/sso/*.proto --go_out=./gen/go/ --go_opt=paths=source_relative --go-grpc_out=./gen/go/ --go-grpc_opt=paths=source_relative

Для генерации файлов достаточно вызвать команду в корне проекта:


task generate

Теперь отправляем текущий проект на GitHub, т.к. он нам понадобится в основном проекте в виде стороннего пакета.



Точка входа и конфигурация


Теперь создаём проект для самого сервиса авторизации. Напомню, в будущем это будет SSO, поэтому планировать будем в более общем виде. Структура будет выглядеть следующим образом:


sso
├── cmd.............. Команды для запуска приложения и утилит
│   ├── migrator.... Утилита для миграций базы данных
│   └── sso......... Основная точка входа в сервис SSO
├── config........... Конфигурационные yaml-файлы
├── internal......... Внутренности проекта
│   ├── app.......... Код для запуска различных компонентов приложения
│   │   └── grpc.... Запуск gRPC-сервера
│   ├── config....... Загрузка конфигурации
│   ├── domain
│   │   └── models.. Структуры данных и модели домена
│   ├── grpc
│   │   └── auth.... gRPC-хэндлеры сервиса Auth
│   ├── lib.......... Общие вспомогательные утилиты и функции
│   ├── services..... Сервисный слой (бизнес-логика)
│   │   ├── auth
│   │   └── permissions
│   └── storage...... Слой работы с данными 
│       └── sqlite.. Реализация на SQLite
├── migrations....... Миграции для базы данных
├── storage.......... Файлы хранилища, например SQLite базы данных
└── tests............ Функциональные тесты

Начнём с самого простого — с точки входа. На самом деле, у нас для неё ничего не готово, мы просто запишем план работ, и будем пошагово реализовывать его:


// `cmd/sso/main.go`
package main

func main() {  
    // TODO: инициализировать объект конфига

    // TODO: инициализировать логгер

    // TODO: инициализировать приложение (app)

    // TODO: запустить gRPC-сервер приложения
}

Начнём по порядку, т.е. с конфига:


// internal/config/config.go
package config

type Config struct {  
    Env            string     `yaml:"env" env-default:"local"`  
    StoragePath    string     `yaml:"storage_path" env-required:"true"`  
    GRPC           GRPCConfig `yaml:"grpc"`  
    MigrationsPath string  
    TokenTTL       time.Duration `yaml:"token_ttl" env-default:"1h"`  
}  

type GRPCConfig struct {  
    Port    int           `yaml:"port"`  
    Timeout time.Duration `yaml:"timeout"`  
}

Это структуры, в которые будет анмаршаллиться (парситься) конфиг-файл. Также мы здесь видим struct-теги (если вы с ними не знакомы, у меня в ТГ-канале есть пост с объяснениями).


Для парсинга конфига-файла я буду использовать библиотеку cleanenv, соответственно и struct-теги мы здесь пишем для для неё. Подробней о них можете почитать в описании библиотеки.


Давайте установим cleanenv:


go get github.com/ilyakaznacheev/cleanenv@v1.5.0

Версию я указываю, чтобы вы понимали, какая была у меня, и при которой код точно будет работать. При желании, можете использовать более актуальную, если читаете статью сильно позже даты публикации.


Вкратце по пунктам:


  • Env — текущее окружение: local, dev, prod и т.п.
  • StoragePath — мы будем использовать SQLite, поэтому нужно указать путь до файла, где хранится наша БД
  • GRPCConfig — порт gRPC-сервиса и таймаут обработки запросов
  • MigrationsPath — путь до директории с миграциями БД. Он будет использоваться утилитой migrator
  • TokenTTL — время жизни выдаваемых токенов авторизации. На самом деле, время жизни — довольно сложная штука, и, по-хорошему, оно должно зависеть от различных факторов. Но мы для простоты сделаем его фиксированным, и будем хранить в конфиге. Далее вы сможете переделать его под себя, при необходимости.

Теперь можем написать функцию MustLoad(), которая будет парсить файл, а также вспомогательную функцию fetchConfigPath() для определения пути до конфиг-файла:


// internal/config/config.go

func MustLoad() *Config {  
    configPath := fetchConfigPath()  
    if configPath == "" {  
        panic("config path is empty") 
    }  

    // check if file exists
    if _, err := os.Stat(configPath); os.IsNotExist(err) {
        panic("config file does not exist: " + configPath)
    }

    var cfg Config

    if err := cleanenv.ReadConfig(configPath, &cfg); err != nil {
        panic("config path is empty: " + err.Error())
    }

    return &cfg
}

// fetchConfigPath fetches config path from command line flag or environment variable.
// Priority: flag > env > default.
// Default value is empty string.
func fetchConfigPath() string {
    var res string

    flag.StringVar(&res, "config", "", "path to config file")
    flag.Parse()

    if res == "" {
        res = os.Getenv("CONFIG_PATH")
    }

    return res
}

По соглашению, функции с префиксом Must вместо возвращения ошибок создают панику. Используйте их с осторожностью.

Зачем нужна функция fetchConfigPath()? Нашему приложению нужно понимать, где искать конфиг-файл, для этого нужно указать configPath. Сделать это можно разными способами, например: флаг --config или переменная окружения CONFIG_PATH. Наша функция реализует оба варианта. Если вдруг указаны оба значения, то будет использован флаг.


То есть, запуск приложения будет выглядеть так в случае переменной окружения:


CONFIG_PATH=./path/to/config/file.yaml myApp

Либо так в случае флага:


myApp --config=./path/to/config/file.yaml

Теперь давайте напишем сам конфиг-файл:


# config/config_local.yaml

env: "local"  
storage_path: "./storage/sso.db"  
grpc:  
  port: 44044  
  timeout: 10h

Возвращаемся к функции main() и создаём там объект конфига:


// cmd/sso/main.go

func main() {
    cfg := config.MustLoad()

    // ...
}

Далее напишем в этом же файле функцию создания объекта логгера. Для логирования мы, конечно же, будем использовать недавно вышедший log/slog, который я очень полюбил ещё до его релиза:


// cmd/sso/main.go

import (
    "log/slog"
    // ...
)

const (
    envLocal = "local"
    envDev   = "dev"
    envProd  = "prod"
)

func main() {
    // ...
}

func setupLogger(env string) *slog.Logger {
    var log *slog.Logger

    switch env {
    case envLocal:
        log = slog.New(
            slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
        )
    case envDev:
        log = slog.New(
            slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
        )
    case envProd:
        log = slog.New(
            slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
        )
    }

    return log
}

Здесь, в зависимости от текущего окружения, создаем логгер с разными параметрами:


  • envLocal: локальный запуск — используем TextHandler, удобный для консоли, и уровень логирования Debug (т.е. будем выводить все сообщения)
  • envDev: запуск на удалённом dev-сервере — уровень логирования тот же, но формат вывода — JSON, удобный для систем сбора логов (Kibana, Grafana Loki и т.п.)
  • envProd: запуск в продакшене: повышаем уровень логирования до Info — нам не нужны debug-логи в проде. Т.е. мы будем получать сообщения только с уровнем Info или Error.

Дописываем создание логгера в main():


// cmd/sso/main.go

// ...

func main() {
    cfg := config.MustLoad()

    log := setupLogger(cfg.Env)
}

// ...

Напомню, что тип текущего окружения мы храним в конфиге — cfg.Env.



gRPC — сервер, обработка запросов


Оставим ненадолго наш main.go и спустимся на уровень ниже — напишем обработчики запросов. Здесь нам понадобятся сгенерированные утилитой protoc Go-файлы, из пакета protos, который мы написали ранее. Для этого подключим этот пакет, в моём случае это выглядит так (вы, соответственно, указываете свой проект):


go get github.com/GolangLessons/protos

Теперь создадим файл, в котором будем описывать обработчики запросов:


// internal/grpc/auth/server.go

import (
    "context"

    "google.golang.org/grpc"

    // Сгенерированный код
    ssov1 "github.com/GolangLessons/protos/gen/go/sso"
)

type serverAPI struct {
    ssov1.UnimplementedAuthServer // Хитрая штука, о ней ниже
    auth Auth
}

// Тот самый интерфейс, котрый мы передавали в grpcApp
type Auth interface {
    Login(
        ctx context.Context,
        email string,
        password string,
        appID int,
    ) (token string, err error)
    RegisterNewUser(
        ctx context.Context,
        email string,
        password string,
    ) (userID int64, err error)
}

func Register(gRPCServer *grpc.Server, auth Auth) {  
    ssov1.RegisterAuthServer(gRPCServer, &serverAPI{auth: auth})  
}

func (s *serverAPI) Login(
    ctx context.Context,
    in *ssov1.LoginRequest,
) (*ssov1.LoginResponse, error) {
    // TODO
}

func (s *serverAPI) Register(
    ctx context.Context,
    in *ssov1.RegisterRequest,
) (*ssov1.RegisterResponse, error) {
    // TODO
}

Здесь мы описали:


  • структуру serverAPI, которая будет реализовывать функционал API
  • каркасы для двух RPC-методов, которые мы будем использовать: Login и Register (методы этой структуры serverAPI)
  • интерфейс будущего Auth из сервисного слоя — его реализацию мы напишем чуть позже, а пока достаточно интерфейса в качестве контракта
  • также здесь у нас есть функция Register, которая регистрирует эту serverAPI в gRPC-сервере

В структуре serverAPI у нас также присутствует вложенная структура UnimplementedAuthServerprotoc генерирует её на основе proto-файла. Она представляет собой некую пустую имплементацию всех методов gRPC сервиса. Использование этой структуры помогает обеспечить обратную совместимость при изменении .proto файла. Если мы добавим новый метод в наш .proto файл и заново сгенерируем код, но не реализуем этот метод в serverAPI, то благодаря встраиванию UnimplementedAuthServer наш код все равно будет компилироваться, а новый метод просто вернет ошибку "Not implemented".


Обратите внимание, что функция регистрации (ssov1.RegisterAuthServer) и объекты запросов / ответов за нас также уже сгенерированы, что очень удобно. Это экономит кучу времени.


Теперь давайте напишем хэндлеры (обработчики) запросов:


// internal/grpc/auth/server.go

import (
    "context"

    ssov1 "github.com/GolangLessons/protos/gen/go/sso"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type Auth interface {
    // .. см. выше
}

func (s *serverAPI) Login(
    ctx context.Context,
    in *ssov1.LoginRequest,
) (*ssov1.LoginResponse, error) {
    if in.Email == "" {
        return nil, status.Error(codes.InvalidArgument, "email is required")
    }

    if in.Password == "" {
        return nil, status.Error(codes.InvalidArgument, "password is required")
    }

    if in.GetAppId() == 0 {
        return nil, status.Error(codes.InvalidArgument, "app_id is required")
    }

    token, err := s.auth.Login(ctx, in.GetEmail(), in.GetPassword(), int(in.GetAppId()))
    if err != nil {
        // Ошибку auth.ErrInvalidCredentials мы создадим ниже
        if errors.Is(err, auth.ErrInvalidCredentials) {
            return nil, status.Error(codes.InvalidArgument, "invalid email or password")
        }

        return nil, status.Error(codes.Internal, "failed to login")
    }

    return &ssov1.LoginResponse{Token: token}, nil
}

Валидацию входных данных можно вынести в отдельную функцию, при желании. Либо даже использовать внешний пакет для валидации. Я же оставлю как есть.


Обратите внимание, что возвращаемую ошибку мы создаем с помощью специальной функции status.Error из библиотеки grpc/status. Это нужно для того, чтобы формат ошибки был понятен любому grpc-клиенту.

Кроме того, мы присваиваем этой ошибке код из пакета grpc/codes — это тоже необходимо для совместимости с клиентами. Рекомендую всегда выбирать подходящие коды ошибок для каждой ситуации. К примеру, если не подошел пароль или мы не нашли пользователя в БД, это — codes.InvalidArgument, если же БД вернула неожиданную ошибку, это уже codes.Internal.


Всё это сделает работу с нашим сервером гораздо более удобной на стороне клиента.


Теперь метод Register():


// internal/grpc/auth/server.go

func (s *serverAPI) Register(
    ctx context.Context,
    in *ssov1.RegisterRequest,
) (*ssov1.RegisterResponse, error) {
    if in.Email == "" {
        return nil, status.Error(codes.InvalidArgument, "email is required")
    }

    if in.Password == "" {
        return nil, status.Error(codes.InvalidArgument, "password is required")
    }

    uid, err := s.auth.RegisterNewUser(ctx, in.GetEmail(), in.GetPassword())
    if err != nil {
        // Ошибку storage.ErrUserExists мы создадим ниже
        if errors.Is(err, storage.ErrUserExists) {
            return nil, status.Error(codes.AlreadyExists, "user already exists")
        }

        return nil, status.Error(codes.Internal, "failed to register user")
    }

    return &ssov1.RegisterResponse{UserId: uid}, nil
}

Как видим, наши хэндлеры очень простые, и на этом они полностью готовы. Вся бизнес-логика будет в сервисном слое, давайте перейдём к нему.



Сервисный слой. Auth


Наш сервис будет регистрировать новых пользователей и логинить их, выдавая токены. Очевидно, что ему нужен некий Storage (хранилище), который умеет сохранять и возвращать информацию о пользователях — это будет уже слой работы с данными. К его реализации мы приступим в следующем разделе, а пока нам, как обычно, достаточно интерфейса.


Для удобства обмена данными между этими слоями опишем модель пользователя:


// internal/domain/models/user.go
package models

type User struct {
    ID       int64
    Email    string
    PassHash []byte
}

Обращаю внимание, что папка models находится в internal/domain — тут будут храниться общие модели для всего домена, а не только для конкретного слоя. То есть, мы можем свободно ими пользоваться, в том числе и для передачи между слоями.


Теперь можем описать интерфейс хранилища:


// internal/services/auth/auth.go

type UserStorage interface {  
    SaveUser(ctx context.Context, email string, passHash []byte) (uid int64, err error)  
    User(ctx context.Context, email string) (models.User, error)  
}

Это хороший вариант, и на работе я чаще вижу именно такой подход. Но лично мне нравится идея поддержки минималистичности каждого интерфейса, поэтому я буду делать так:


// internal/services/auth/auth.go

type UserSaver interface {
    SaveUser(
        ctx context.Context,
        email string,
        passHash []byte,
    ) (uid int64, err error)
}

type UserProvider interface {  
    User(ctx context.Context, email string) (models.User, error)  
}

То есть, у нас тут два разных интерфейса, у каждого из которых своя узкая область применимости. Это делает наш код более гибким, ведь кто сказал, что за сохранение и получение пользователей обязана отвечать одна система? Возможно, мы захотим сохранять асинхронно через кафку, а получать вообще gRPC / HTTP запросом? Конечно, что это редкие кейсы, и не нужно затачивать код под всё подряд, но ведь и цена за это практически нулевая.


Кроме того, минималистичные интерфейсы проще реализовывать и тестировать. Всяко удобней, чем некий общий UserStorage, состоящий из 50 методов, и который во всех случаях нужно реализовывать целиком, даже если используется лишь один метод.


Вы же можете выбрать любой вариант из представленных, какой вам больше нравится.


По поводу размещения интерфейса — да, я обычно размещаю их в месте использования, а не рядом с реализацией. Почему так, я рассказывал в отдельном ролике.


Двигаемся дальше. Пользователь у нас будет логиниться не во все приложения сразу, а только в одно какое-то конкретное, и его JWT-токен подписывается ключом конкретного приложения. В нашем случае, он логинится в URL Shortener. Это значит, что помимо работы с пользователями, нам необходимо также получать информацию о приложениях (App). Для начала, нам достаточно ID и секретного ключа. Заведём для этого модель App:


// internal/domain/models/app.go

package models

type App struct {
    ID     int
    Name   string
    Secret string
}

И интерфейс для получения App из хранилища:


// internal/services/auth/auth.go
// ...
type AppProvider interface {  
    App(ctx context.Context, appID int) (models.App, error)  
}

Теперь мы можем написать конструктор для сервиса Auth:


// internal/services/auth/auth.go

// ...

type Auth struct {
    log         *slog.Logger
    usrSaver    UserSaver
    usrProvider UserProvider
    appProvider AppProvider
    tokenTTL    time.Duration
}

func New(  
    log *slog.Logger,  
    userSaver UserSaver,  
    userProvider UserProvider,  
    appProvider AppProvider,  
    tokenTTL time.Duration,  
) *Auth {  
    return &Auth{  
       usrSaver:    userSaver,  
       usrProvider: userProvider,  
       log:         log,  
       appProvider: appProvider,  
       tokenTTL:    tokenTTL,  // Время жизни возвращаемых токенов
    }  
}

Обратите внимание — мы передаём userSaver, userProvider и appProvider отдельными параметрами, хотя у них у всех будет общая реализация. Т.е. мы будем передавать один объект в три аргумента.

Кому-то это может показаться избыточным, и доля истины в этом действительно есть. Поэтому повторюсь — вы можете объединить эти три интерфейса в один, это не помешает вам пройти гайд до конца. Либо можете согласиться с моими доводами выше и делать по аналогии.


И ещё один момент, который нам тут понадобится. В пакете log/slog мне очень не хватает удобного способа добавления ошибки в сообщение лога. И чтобы было удобно, я обычно пишу простенькую вспомогательную функцию:


// internal/lib/logger/sl/sl.go

package sl

import (
    "log/slog"
)

func Err(err error) slog.Attr {
    return slog.Attr{
        Key:   "error",
        Value: slog.StringValue(err.Error()),
    }
}

И теперь ошибки можно логировать вот таким образом:


if err != nil {
    a.log.Error("failed to get user", sl.Err(err))
}

Метод RegisterNewUser


Переходим наконец к бизнес-логике — к методам RegisterNewUser и Login. Начнём с первого. Для него нам также понадобится внешний пакет crypto/bcrypt — чуть ниже я расскажу что это и зачем, а пока давайте её установим:


go get golang.org/x/crypto@v0.13.0

// internal/services/auth/auth.go

import (
    // ...
    "golang.org/x/crypto/bcrypt"

    "grpc-service-ref/internal/lib/logger/sl"
)

// RegisterNewUser registers new user in the system and returns user ID.
// If user with given username already exists, returns error.
func (a *Auth) RegisterNewUser(ctx context.Context, email string, pass string) (int64, error) {
    // op (operation) - имя текущей функции и пакета. Такую метку удобно
    // добавлять в логи и в текст ошибок, чтобы легче было искать хвосты
    // в случае поломок.
    const op = "Auth.RegisterNewUser"

    // Создаём локальный объект логгера с доп. полями, содержащими полезную инфу
    // о текущем вызове функции
    log := a.log.With(
        slog.String("op", op),
        slog.String("email", email),
    )

    log.Info("registering user")

    // Генерируем хэш и соль для пароля.
    passHash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
    if err != nil {
        log.Error("failed to generate password hash", sl.Err(err))

        return 0, fmt.Errorf("%s: %w", op, err)
    }

    // Сохраняем пользователя в БД
    id, err := a.usrSaver.SaveUser(ctx, email, passHash)
    if err != nil {
        log.Error("failed to save user", sl.Err(err))

        return 0, fmt.Errorf("%s: %w", op, err)
    }

    return id, nil
}

Здесь важно разобраться, что делает функция bcrypt.GenerateFromPassword(), а для этого необходимо понимание принципов хранения паролей. Поэтому, проведу очень краткий ликбез по этой теме. Как обычно, учитывайте, что это лишь отправная точка, и более подробную информацию ищите в интернете. Если же вам это знакомо, то можете смело пропускать ликбез.


Ликбез по хранению паролей


В чистом виде пароль хранить нельзя — это чревато утечками: если злоумышленник украдёт нашу БД, он узнает пароли всех пользователей, получит доступ к их аккаунтам и т.п. Очень плохо, очень неприятно.


Чтобы этого избежать, мы можем брать хэш от пароля и хранить уже его (если не знаете что такое хэш, у меня есть об этом видео), а когда пользователь попытается залогиниться, мы снова возьмём хэш от полученного пароля и сравним хэши вместо прямого сравнения двух паролей.


Но это тоже опасно, т.к. если злоумышленник подберёт или украдёт хотя бы один пароль, то он автоматически получает доступ ко всем пользователям с таким же паролем (т.к. у них будет такой же хэш). Кроме того, есть способы восстановить по хэшу пароль, особенно если это простенький популярный пароль.


Что же делать? Решение простое — создаём случайную строку (её называют — соль, salt), добавляем её к паролю: <password>+<salt>, берём хэш от того что получилось, сохраняем в БД и рядом в открытом виде также сохраняем соль. Когда пользователь будет логиниться, мы будем аналогично добавлять соль к присылаемым паролям и сравнивать хэши. Такой вариант намного безопасней, пароль сложнее восстановить и слив одного пароля не поможет подобрать остальные.


Это очень популярный подход, вы легко найдёте более подробную информацию о нём в интернете.


Как работает функция bcrypt.GenerateFromPassword


Функция bcrypt.GenerateFromPassword() выполняет процедуру генерации хэша и соли за нас. Она принимает на вход пароль, генерирует соль, хэширует и возвращает результат единой строкой. В этой строке содержится одновременно и хэш, и соль, что очень удобно — ведь нам достаточно сохранить единственное значение.


У этой функции есть второй аргумент — bcrypt.DefaultCost. Чем выше значение этого параметра, тем лучше защищен пароль, но тем сложнее алгоритм его сохранения и сравнения. Для пет-проекта лучше выбрать DefaultCost, а для более серьезных проектов стоит изучить эту тему глубже.


Таким образом, в случае попытки авторизации мы используем функцию bcrypt.CompareHashAndPassword(savedHash, loginPassword), которая выполнит за нас всю работу по сравнению и сообщит — подходит пароль или нет.


Перед тем как перейти непосредственно к методу Login, нам необходимо написать код для генерации JWT-токенов, т.к. результат работы этого метода — получение токена авторизации.


Генерация JWT-токена для авторизации


Для работы с JWT мы будем использовать следующую библиотеку:


go get "github.com/golang-jwt/jwt/v5"@v5.0.0

Токен будет содержать в себе информацию о пользователе и о текущем приложении. Можно все эти параметры передавать длинным списком аргументов, либо можно передавать сразу модели User и App. Мне нравится последний вариант:


Теперь можем написать функцию генерации токена. Я разместил её в пакете internal/lib/jwt:


// internal/lib/jwt/jwt.go

// NewToken creates new JWT token for given user and app.
func NewToken(user models.User, app models.App, duration time.Duration) (string, error) {  
    token := jwt.New(jwt.SigningMethodHS256)  

    // Добавляем в токен всю необходимую информацию
    claims := token.Claims.(jwt.MapClaims)  
    claims["uid"] = user.ID  
    claims["email"] = user.Email  
    claims["exp"] = time.Now().Add(duration).Unix()  
    claims["app_id"] = app.ID  

    // Подписываем токен, используя секретный ключ приложения
    tokenString, err := token.SignedString([]byte(app.Secret))  
    if err != nil {  
       return "", err  
    }  

    return tokenString, nil  
}

Обратите внимание на эту строчку: claims["exp"] = time.Now().Add(duration).Unix(). В ней мы задаём срок действия (TTL) токена в виде конкретной временной метки, до которой он будет считаться валидным. После этого дедлайна токен будет считаться "протухшим", на стороне клиента мы его не будем принимать.


NewToken — довольно простая функция, но советую вам самостоятельно написать для неё тесты — это и полезная практика, и поможет вам в будущем, если будете развивать проект.


Метод Login


Теперь напишем метод Login.


Обратите внимание! Текущая реализация метода имеет одну критичную дыру в безопасности — он не защищен от брутфорса (перебора паролей). Я решил не усложнять логику разбором этой темы. Советую вам быть аккуратней — желательно, придумать какую-то логику для защиты. Либо напишите в комментариях, что вам интересна эта тема, я постараюсь написать об этом отдельную заметку в ТГ-канале, либо в виде отдельного поста.

Он заметно длиннее предыдущего, но при этом довольно простой, поэтому также покажу его целиком и прокомментирую:


// internal/services/auth/auth.go

var (
    ErrInvalidCredentials = errors.New("invalid credentials")
)

// Login checks if user with given credentials exists in the system and returns access token.
//
// If user exists, but password is incorrect, returns error.
// If user doesn't exist, returns error.
func (a *Auth) Login(
    ctx context.Context,
    email string,
    password string, // пароль в чистом виде, аккуратней с логами!
    appID int, // ID приложения, в котором логинится пользователь
) (string, error) {
    const op = "Auth.Login"

    log := a.log.With(
        slog.String("op", op),
        slog.String("username", email),
        // password либо не логируем, либо логируем в замаскированном виде
    )

    log.Info("attempting to login user")

    // Достаём пользователя из БД
    user, err := a.usrProvider.User(ctx, email)
    if err != nil {
        if errors.Is(err, storage.ErrUserNotFound) {
            a.log.Warn("user not found", sl.Err(err))

            return "", fmt.Errorf("%s: %w", op, ErrInvalidCredentials)
        }

        a.log.Error("failed to get user", sl.Err(err))

        return "", fmt.Errorf("%s: %w", op, err)
    }

    // Проверяем корректность полученного пароля
    if err := bcrypt.CompareHashAndPassword(user.PassHash, []byte(password)); err != nil {
        a.log.Info("invalid credentials", sl.Err(err))

        return "", fmt.Errorf("%s: %w", op, ErrInvalidCredentials)
    }

    // Получаем информацию о приложении
    app, err := a.appProvider.App(ctx, appID)
    if err != nil {
        return "", fmt.Errorf("%s: %w", op, err)
    }

    log.Info("user logged in successfully")

    // Создаём токен авторизации
    token, err := jwt.NewToken(user, app, a.tokenTTL)
    if err != nil {
        a.log.Error("failed to generate token", sl.Err(err))

        return "", fmt.Errorf("%s: %w", op, err)
    }

    return token, nil
}

На этом сервис Auth полностью готов, можем переходить к реализации хранилища.



Слой работы с данными — Storage


Для хранения данных я буду использовать SQLite — очень люблю её использовать для пет-проектов, т.к. не нужно возиться с инфраструктурой, вся БД хранится в одном файле, а для работы с ней достаточно импорта драйвера sqlite в виде зависимости через go mod:


go get github.com/mattn/go-sqlite3@v1.14.17

Он нам пригодится чуть позже.


Код пакета хранилища я размещу в internal/storage, а реализацию для sqlite положу сюда: internal/storage/sqlite.


В основном пакете storage я храню лишь общие описания типов, ошибок и т.п. В моём текущем случае — это только ошибки:


// internal/storage/storage.go
package storage  

import "errors"  

var (  
    ErrUserExists   = errors.New("user already exists")  
    ErrUserNotFound = errors.New("user not found")  
    ErrAppNotFound  = errors.New("app not found")  
)

По этим ошибкам сервисный слой сможет понять, что конкретно пошло не так, и принимать соответствующие решения. Они не должны зависеть от конкретной реализации хранилища (будь то SQLite, Postgres, MongoDB и т.п.), поэтому мы их разместили в общем пакете.


Миграции и схема данных


Теперь нам нужно подготовить схему данных. По просьбам читателей / зрителей прошлой статьи / ролика по REST API, в этот раз я буду использовать полноценные миграции для работы со схемой БД.


Миграции БД — это пошаговые изменения структуры базы данных, позволяющие последовательно применять и откатывать модификации схемы. Они, обычно, представляют из себя набор файлов с SQL-запросами, применяя которые по очереди, вы приведете схему БД к актуальному состоянию. Это нужно для того, чтобы обновив репозиторий с кодом, можно было бы так же легко актуализировать БД.


Если вам не особо понятно эта концепция, это не страшно, на конкретном примере станет яснее. Поэтому давайте перейдем к реализации. Мы, конечно же, возьмём готовое решение:


go get github.com/golang-migrate/migrate/v4@v4.16.2

Различных миграторов на Go довольно много, и все они плюс-минус неплохие, так что выбор не принципиален.


Как пользоваться мигратором? Есть несколько вариантов:


  • Запускать в Docker-контейнере (популярный вариант)
  • Установить в виде исполняемого файла и запускать его
  • Написать в текущем проекте собственную утилиту-враппер

Мне больше нравится последний вариант, т.к. его удобней контролировать. Но вы можете выбрать любой из них и сделать самостоятельно, это не сложно.


Создаём файл cmd/migrator/main.go и пишем в нём простенькую обёртку:


// cmd/migrator/main.go

package main

import (
    "flag"
    "fmt"

    // Библиотека для миграций
    "github.com/golang-migrate/migrate/v4"
    // Драйвер для выполнения миграций SQLite 3
    _ "github.com/golang-migrate/migrate/v4/database/sqlite3"
    // Драйвер для получения миграций из файлов
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func main() {
    var storagePath, migrationsPath, migrationsTable string

    // Получаем необходимые значения из флагов запуска

    // Путь до файла БД.
    // Его достаточно, т.к. мы используем SQLite, другие креды не нужны.
    flag.StringVar(&storagePath, "storage-path", "", "path to storage")
    // Путь до папки с миграциями.
    flag.StringVar(&migrationsPath, "migrations-path", "", "path to migrations")
    // Таблица, в которой будет храниться информация о миграциях. Она нужна 
    // для того, чтобы понимать, какие миграции уже применены, а какие нет.
    // Дефолтное значение - 'migrations'.
    flag.StringVar(&migrationsTable, "migrations-table", "migrations", "name of migrations table")
    flag.Parse() // Выполняем парсинг флагов

    // Валидация параметров
    if storagePath == "" {
        // Простейший способ обработки ошибки :)
        // При необходимости, можете выбрать более подходящий вариант.
        // Меня паника пока устраивает, поскольку это вспомогательная утилита.
        panic("storage-path is required")
    }
    if migrationsPath == "" {
        panic("migrations-path is required")
    }

    // Создаем объект мигратора, передав креды нашей БД
    m, err := migrate.New(
        "file://"+migrationsPath,
        fmt.Sprintf("sqlite3://%s?x-migrations-table=%s", storagePath, migrationsTable),
    )
    if err != nil {
        panic(err)
    }

    // Выполняем миграции до последней версии
    if err := m.Up(); err != nil {
        if errors.Is(err, migrate.ErrNoChange) {
            fmt.Println("no migrations to apply")

            return
        }

        panic(err)
    }
}

Обратите внимание на то, что мы здесь вынесли нейминг таблицы для миграций в отдельный флаг с помощью параметра ?x-migrations-table=%s. Обычно это не обязательно, но я буду хранить отдельный набор миграций для тестов, и информация о них будет храниться в отдельной таблице. Но об этом позже — в разделе про тестирование.


У выбранной нами библиотеки для миграций следующий формат нейминга миграций: <number>_<title>.<direction>.sql, где:


  • number — используется для определения порядка применения миграций, они выполняются по возрастанию номеров. Тут должно быть любое целое число — это может быть порядковый номер, timestamp и т.п. Я буду именовать по порядку: 1, 2, 3 и т.д.
  • title — игнорируется библиотекой, он нужен только для людей, чтобы проще было ориентироваться в списке миграций
  • direction — значение up или down. Файлы с параметром up в имени обновляют схему до новой версии, down — откатывают изменения.

Создаём первую миграцию в папке ./migrations:


-- migrations/1_init.up.sql

CREATE TABLE IF NOT EXISTS users
(
    id           INTEGER PRIMARY KEY,
    email        TEXT    NOT NULL UNIQUE,
    pass_hash    BLOB    NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email ON users (email);

CREATE TABLE IF NOT EXISTS apps
(
    id     INTEGER PRIMARY KEY,
    name   TEXT NOT NULL UNIQUE,
    secret TEXT NOT NULL UNIQUE
);

Эта миграция создаёт все необходимые таблицы и индексы. Таблицы довольно простенькие, и выше мы уже обсуждали модели данных, поэтому тут всё должно быть понятно.


Обратите внимание, что параметры email, name и secret должны быть уникальными и мы добавили для них соответствующий constraint.


Обратная миграция:


-- ./migrations/1_init.down.sql

DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS apps;

Заведём папку под хранения файла БД:


mkdir storage

Теперь можем запустить утилиту, указав путь до файла БД и папки с миграциями:


go run ./cmd/migrator --storage-path=./storage/sso.db --migrations-path=./migrations

Если всё прошло хорошо, у вас должен появиться файл БД (./storage/sso.db) с актуальной схемой.


Реализация Storage для SQLite 3


Этот раздел написан довольно минималистично, поскольку мы делаем фокус на gRPC, а не на взаимодействии с БД. Если вы знакомы с пакетом database/sql, то вам всё будет понятно. Если нет, то советую ознакомиться, он огромной вероятностью рано или поздно пригодится вам на работе.


В любом случае, даже если что-то непонятно, можете пока делать по аналогии, концетрируясь на основной теме, но когда закончите, у вас уже будет следующая тема для изучения.


Также, если вы не работали с SQLite в Go, советую посмотреть мой ролик на эту тему.


Выше мы уже установили драйвер для работы с SQLite. Если пропустили, напоминаю:


go get github.com/golang-migrate/migrate/v4@v4.16.2

Теперь создаём файл, в котором опишем тип Storage и конструктор для него:


// internal/storage/sqlite/sqlite.go

import (
    "fmt"

    "github.com/mattn/go-sqlite3"
)

type Storage struct {
    db *sql.DB
}

// Конструктор Storage
func New(storagePath string) (*Storage, error) {
    const op = "storage.sqlite.New"

    // Указываем путь до файла БД
    db, err := sql.Open("sqlite3", storagePath)
    if err != nil {
        return nil, fmt.Errorf("%s: %w", op, err)
    }

    return &Storage{db: db}, nil
}

Для хранилища нам нужно реализовать три метода: SaveUser(), User(), App(), начнём первого:


// internal/storage/sqlite/sqlite.go

import (
    "context"
    "database/sql"
    "errors"
    "fmt"

    "grpc-service-ref/internal/storage"

    "github.com/mattn/go-sqlite3"
)

// SaveUser saves user to db.
func (s *Storage) SaveUser(ctx context.Context, email string, passHash []byte) (int64, error) {
    const op = "storage.sqlite.SaveUser"

    // Простенький зпрос на добавление пользователя
    stmt, err := s.db.Prepare("INSERT INTO users(email, pass_hash) VALUES(?, ?)")
    if err != nil {
        return 0, fmt.Errorf("%s: %w", op, err)
    }

    // Выполняем запрос, передав параметры
    res, err := stmt.ExecContext(ctx, email, passHash)
    if err != nil {
        var sqliteErr sqlite3.Error

        // Небольшое кунг-фу для выявления ошибки ErrConstraintUnique
        // (см. подробности ниже)
        if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
            return 0, fmt.Errorf("%s: %w", op, storage.ErrUserExists)
        }

        return 0, fmt.Errorf("%s: %w", op, err)
    }

    // Получаем ID созданной записи
    id, err := res.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("%s: %w", op, err)
    }

    return id, nil
}

Разберём подробно только вот эту обработку ошибки:


if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
        return 0, fmt.Errorf("%s: %w", op, storage.ErrUserExists)
}

Суть этой конструкции в том, чтобы выявить ошибку нарушения констрэинта уникальности по email, другими словами — когда мы пытаемся добавить в таблицу запись с параметром email, который уже есть в таблице. Если мы её выявляем, то наружу нужно вернуть ошибку storage.ErrUserExists, которую мы подготовили заранее.


Это нужно для того, чтобы вне зависимости от используемой БД всегда можно было определить попытку добавления дубликата имеющегося пользователя. Нам это понадобится в других слоях, чтобы отреагировать на подобный случай правильным образом.


Идём дальше, напишем метод User(ctx context.Context, email string) для получения пользователя по email:


// internal/storage/sqlite/sqlite.go

import (
    "context"
    "database/sql"
    "errors"
    "fmt"

    "grpc-service-ref/internal/domain/models"
    "grpc-service-ref/internal/storage"
)

// User returns user by email.
func (s *Storage) User(ctx context.Context, email string) (models.User, error) {
    const op = "storage.sqlite.User"

    stmt, err := s.db.Prepare("SELECT id, email, pass_hash FROM users WHERE email = ?")
    if err != nil {
        return models.User{}, fmt.Errorf("%s: %w", op, err)
    }

    row := stmt.QueryRowContext(ctx, email)

    var user models.User
    err = row.Scan(&user.ID, &user.Email, &user.PassHash)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return models.User{}, fmt.Errorf("%s: %w", op, storage.ErrUserNotFound)
        }

        return models.User{}, fmt.Errorf("%s: %w", op, err)
    }

    return user, nil
}

Здесь мы аналогично определяем ошибку, но на этот раз нас интересует sql.ErrNoRows, она означает что мы не смогли найти соответствующую запись. В этом случае мы вернём наружу storage.ErrUserNotFound.


Остался последний метод — App(ctx context.Context, id int)


// App returns app by id.
func (s *Storage) App(ctx context.Context, id int) (models.App, error) {
    const op = "storage.sqlite.App"

    stmt, err := s.db.Prepare("SELECT id, name, secret FROM apps WHERE id = ?")
    if err != nil {
        return models.App{}, fmt.Errorf("%s: %w", op, err)
    }

    row := stmt.QueryRowContext(ctx, id)

    var app models.App
    err = row.Scan(&app.ID, &app.Name, &app.Secret)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return models.App{}, fmt.Errorf("%s: %w", op, storage.ErrAppNotFound)
        }

        return models.App{}, fmt.Errorf("%s: %w", op, err)
    }

    return app, nil
}

Как и в предыдущих случаях, в случае отсутствия записи (sql.ErrNoRows), возвращаем наружу storage.ErrAppNotFound.


На этом наш Storage готов, и мы собрать всё это дело воедино.



Собираем компоненты приложения воедино


Итак, мы написали Auth (сервисный слой) и Storage (слой работы с данными), теперь нам нужно подключить их к приложению.


Само приложение у нас будет представлять пакет internal/app. Нет, не cmd/sso/main.go — это была лишь точка входа. Такой подход делает код main намного проще, и, главное — даёт возможность создавать экземпляр приложения в других местах — например, в тестах, что облегчает тестирование.


При этом, gRPC-сервер мы завернём в ещё одно отдельное приложение (internal/app/grpc) вместе со всеми зависимостями. Давайте с этого и начнём:


// internal/app/grpc/app.go

package grpcapp

import (
    "context"
    "log/slog"

    "google.golang.org/grpc"
)

type App struct {
    log        *slog.Logger
    gRPCServer *grpc.Server
    port       int // Порт, на котором будет работать grpc-сервер
}

Здесь мы описали тип, который будет представлять приложение gRPC-сервера и интерфейс для сервисного слоя — в нашем случае это только Auth, но потенциально сервисов может быть больше.


Далее напишем конструктор. В нём мы будем использовать библиотеку grpc-ecosystem/go-grpc-middleware, содержащую готовые реализации некоторых полезных интерсепторов (подробнее о них будет ниже). Давайте её установим:


go get github.com/grpc-ecosystem/go-grpc-middleware/v2@v2.0.0

Теперь можем написать сам конструктор. Кода в нём не много, но есть непростые моменты, поэтому будет разбираться поэтапно:


// internal/app/grpc/app.go
// ...

// New creates new gRPC server app.
func New(log *slog.Logger, authService authgrpc.Auth, port int) *App {
    // TODO: создать gRPCServer и подключить к нему интерсепторы

    // TODO: зарегистрировать у сервера наш gRPC-сервис Auth

    // TODO: вернуть объект App со всеми необходимыми полями
}

Один из параметров authgrpc.Auth — это интерфейс сервисного слой, не путать gRPC-сервисом Auth. Его мы напишем чуть ниже.


Сервер создаётся следующим образом:


gRPCServer := grpc.NewServer(opts)

На вход он принимает различные опции, и в нашем случае это будут только интерсепторы (Interceptors).


Интерсептор gRPC это, в некотором смысле, аналог Middleware из мира HTTP / REST серверов. То есть, это функция, которая вызывается перед и/или после обработки RPC-вызова на стороне сервера или клиента. С помощью интерсепторов мы можем выполнять различные полезные действия (например, логирование запросов, аутентификацию, авторизация и др.), не изменяя основной логики обработки RPC.


Допишем в конструкторе создание и регистрацию gRPC-сервера:


// internal/app/grpc/app.go
package grpcapp

import (
    // ...
    "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery"
)

// ...

// New creates new gRPC server app.
func New(log *slog.Logger, authService AuthService, port int) *App {
    // Создаём новый сервер с единственным интерсептором
    gRPCServer := grpc.NewServer(grpc.ChainUnaryInterceptor(
        recovery.UnaryServerInterceptor(),
    ))

    // Регистрируем наш gRPC-сервис Auth, об этом будет ниже
    authgrpc.Register(gRPCServer, authService)

    return &App{
        log:        log,
        gRPCServer: gRPCServer,
        port:       port,
    }
}

У нас пока всего один интерсептор, и я его обернул grpc.ChainUnaryInterceptor — это некий враппер, который принимает в качестве аргументов набор интерсепторов, а когда приходит одиночный запрос (Unary), запускает все эти интерсепторы поочерёдно (об этом говорит слово Chain в названии).


Помимо одиночных запросов, gRPC умеет работать также с потоковыми (Stream), и для них мы бы использовали grpc.ChainStreamInterceptor, но это уже другая история, со стримами в этой статье мы работать не будем.

Интерсептор recovery.UnaryServerInterceptor восстановит и обработает панику, если она случится внутри хэндлера. Полезная штука, ведь мы не хотим, чтобы паника в одном запросе уронила нам весь сервис, остановив обработку даже корректных запросов. Вообще, восстановление паники, это порой дискутивная тема, и если вам такой подход не нравится, можете просто не добавлять этот интерсептор.


В текущем виде паника просто молча восстанавливается, и если мы хотим добавить какие-то свои действия, то можно написать свой RecoveryHandler. К примеру, давайте добавим хэндлер, который будет логировать содержимое паники, чтобы мы это заметили:


recoveryOpts := []recovery.Option{
    recovery.WithRecoveryHandler(func(p interface{}) (err error) {
        // Логируем информацию о панике с уровнем Error
        log.Error("Recovered from panic", slog.Any("panic", p))

        // Можете либо честно вернуть клиенту содержимое паники
        // Либо ответить - "internal error", если не хотим делиться внутренностями
        return status.Errorf(codes.Internal, "internal error")
    }),
}

gRPCServer := grpc.NewServer(grpc.ChainUnaryInterceptor(
    recovery.UnaryServerInterceptor(recoveryOpts...),
))

Поскольку, из мониторинга у нас только логи, этого достаточно. Но я советую вам самостоятельно прикрутить метрики и алерты, и в таком случае в обработчике паники обязательно нужно отправлять соответствующую метрику и правильно настроить алерт, это важно.


Давайте добавим еще один важный интерсептор — который будет логировать все входящие запросы и ответы. Это бывает очень полезно для поиске хвостов в случае поломок и дебага.


Для этого мы также возьмём готовое решение из пакета: logging.UnaryServerInterceptor(log, opts) (пример импорта см. ниже). На вход он принимает логгер и опции. К сожалению, мы не можем просто передать наш текущий логгер, т.к. у его метода Log() немного отличается сигнатура, от той которую требует хэндлер:


// have 
Log(context.Context, slog.Level, string, ...any)
// need
Log(context.Context, logging.Level, string, ...any)

Это значит, что нам снова нужен простенький враппер:


// InterceptorLogger adapts slog logger to interceptor logger.
// This code is simple enough to be copied and not imported.
func InterceptorLogger(l *slog.Logger) logging.Logger {
    return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
        l.Log(ctx, slog.Level(lvl), msg, fields...)
    })
}

Здесь мы просто конвертируем имеющуюся функцию Log() в аналогичную из пакета интерсептора.


Помимо логгера, данный интерсептор принимает опции. К примеру, можем передать ему такие параметры:


loggingOpts := []logging.Option{
    logging.WithLogOnEvents(
        logging.PayloadReceived, logging.PayloadSent,
    ),
}

Таким образом мы сообщим интерсептору, что, помимо прочего, мы также хотим логировать тело запроса и ответа. Такая опция далеко не всегда уместна, т.к. в теле запросов может находиться информация, которую логировать ни как нельзя. К примеру, пароли в чистом виде.


Поэтому, в качестве упражнения, я предлагаю вам дописать код, который будет маскировать пароль в логах — это можно сделать, например, модифицировав написаный выше InterceptorLogger(). Маскировать можно разными способами: заменять пароль символами звездочки, заменять звездочками лишь часть пароля и т.п.


Обратите внимание, если вы не маскируете пароль, это потенциальная дыра в безопасности! В таком случае лучше вообще отказаться от логирования тела запроса.

Теперь можем добавить всё это к конструктор, и в итоге получаем такой код:


// internal/app/grpc/app.go

import (
    "log/slog"

    authgrpc "grpc-service-ref/internal/grpc/auth"

    "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
    "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// ...

// New creates new gRPC server app.
func New(
    log *slog.Logger,
    authService authgrpc.Auth,
    port int,
) *App {
    loggingOpts := []logging.Option{
        logging.WithLogOnEvents(
            logging.PayloadReceived, logging.PayloadSent,
        ),
    }

    recoveryOpts := []recovery.Option{
        recovery.WithRecoveryHandler(func(p interface{}) (err error) {
            log.Error("Recovered from panic", slog.Any("panic", p))

            return status.Errorf(codes.Internal, "internal error")
        }),
    }

    gRPCServer := grpc.NewServer(grpc.ChainUnaryInterceptor(
        recovery.UnaryServerInterceptor(recoveryOpts...),
        logging.UnaryServerInterceptor(InterceptorLogger(log), loggingOpts...),
    ))

    authgrpc.Register(gRPCServer, authService)

    return &App{
        log:        log,
        gRPCServer: gRPCServer,
        port:       port,
    }
}

Помимо этих двух, вам могут также понадобиться интерсепторы для следующих целей: трейсинг, метрики, алерты, авторизация и др. Но в текущей статье нам этого достаточно.


Также здесь вы могли заметить функцию authgrpc.Register, которая регистрирует реализацию сервиса аутентификации (authService) на нашем gRPC сервере (gRPCServer). В контексте gRPC это обычно означает, что сервер будет знать, как обрабатывать входящие RPC-запросы, связанные с этим сервисом аутентификации, потому что реализация этого сервиса (методы, которые она предоставляет) теперь связаны с сервером gRPC.


Осталось лишь научить это приложение запускаться:


// internal/app/grpc/app.go

// MustRun runs gRPC server and panics if any error occurs.
func (a *App) MustRun() {
    if err := a.Run(); err != nil {
        panic(err)
    }
}

// Run runs gRPC server.
func (a *App) Run() error {
    const op = "grpcapp.Run"

    // Создаём listener, который будет слушить TCP-сообщения, адресованные
    // Нашему gRPC-серверу
    l, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port))
    if err != nil {
        return fmt.Errorf("%s: %w", op, err)
    }

    a.log.Info("grpc server started", slog.String("addr", l.Addr().String()))

    // Запускаем обработчик gRPC-сообщений
    if err := a.gRPCServer.Serve(l); err != nil {
        return fmt.Errorf("%s: %w", op, err)
    }

    return nil
}

Если не удалось запустить gRPC-сервис, то нет смысла идти по коду дальше, поэтому можем смело использовать MustRun(), в котором будем падать с паникой, если случилась какая-то ошибка.


Теперь можем создать grpcApp внутри основного приложения:


// internal/app/app.go

package app

import (
    "log/slog"
    "time"

    grpcapp "grpc-service-ref/internal/app/grpc"
    "grpc-service-ref/internal/services/auth"
    "grpc-service-ref/internal/storage/sqlite"
)

type App struct {
    GRPCServer *grpcapp.App
}

func New(
    log *slog.Logger,
    grpcPort int,
    storagePath string,
    tokenTTL time.Duration,
) *App {
    storage, err := sqlite.New(storagePath)
    if err != nil {
        panic(err)
    }

    authService := auth.New(log, storage, storage, storage, tokenTTL)

    grpcApp := grpcapp.New(log, authService, grpcPort)

    return &App{
        GRPCServer: grpcApp,
    }
}

Понимаю, что многих может смущать эта строчка:


authService := auth.New(log, storage, storage, storage, tokenTTL)

А именно, трижды передаваемый storage. Увы, таковы издержки минималистичных интерфейсов. Но подумайте о том, что не во всех случаях реализациями этих интерфейсов может быть storage, это даёт нам больше гибкости. В любом случае, если эта концепция вам не по душе, вы всегда вольны сделать по своему.


Теперь можем создать и запустить приложение в cmd/sso. В итоге, наш main.go будет выглядеть следующим образом:


// cmd/sso/main.go

package main

import (
    "log/slog"
    "os"

    "grpc-service-ref/internal/app"
    "grpc-service-ref/internal/config"
)

const (
    envLocal = "local"
    envDev   = "dev"
    envProd  = "prod"
)

func main() {
    cfg := config.MustLoad()

    log := setupLogger(cfg.Env)

    application := app.New(log, cfg.GRPC.Port, cfg.StoragePath, cfg.TokenTTL)

    application.GRPCServer.MustRun()
}

func setupLogger(env string) *slog.Logger {
    // ...
}

Вместо строчки application.GRPCServer.MustRun() можете научить своё основное приложение автоматически запускать все внутренние, а не дёргать запуск внутренних в main(). То есть, выглядеть это будет так:


application.MustRun()

Но мне текущий вариант нравится больше.


Graceful shutdown — правильная остановка приложения


В текущем виде наш сервис прекрасно работает. А теперь давайте научим его также прекрасно завершать свою работу при необходимости. Речь, конечно же, про graceful shutdown.


Graceful shutdown — это процесс корректного завершения работы приложения, при котором оно завершает текущие операции и освобождает ресурсы перед окончательным выключением

Простыми словами, если приложение понимает, что его попросили завершить работу, оно перед этим должно сделать некоторые важные действия: например, не обрывать запросы посреди работы.


Чтобы научить наше приложение grpcApp правильно завершать работу, добавим ему метод Stop():


// internal/app/grpc/app.go

// Stop stops gRPC server.
func (a *App) Stop() {
    const op = "grpcapp.Stop"

    a.log.With(slog.String("op", op)).
        Info("stopping gRPC server", slog.Int("port", a.port))

    // Используем встроенный в gRPCServer механизм graceful shutdown
    a.gRPCServer.GracefulStop()
}

Как видим, нам почти ничего не пришлось для этого делать самостоятельно — в используемом нами gRPCServer *grpc.Server уже есть подходящий метод. Что он делает:


  1. Перестаёт принимать новые запросы
  2. Ждёт завершения обработки текущих запросов
  3. Возвращает управление

Теперь в main() нам нужно в правильный момент вызвать этот метод. Для этого нам нужно научить программу перехватывать сигналы SIGINT и SIGTERM от ОС (т.е. понимать, когда от ОС пришла команда остановки работы). Для этого обычно используется такой подход:


// Создаём канал для передачи информации о сигналах
stop := make(chan os.Signal, 1)
// "Слушаем" перечисленные сигналы
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)

// Ждём данных из канала
<-stop

// TODO: здесь будет код graceful shutdown

Пока сигналов нет, команда <-stop будет блокировать дальнейшее выполнение выполнение кода, т.е. приложение будет работать, и мы не выйдем из main() раньше времени. Как только придет соответствующий сигнал, в канал stop придет значение, и мы двинемся дальше — выполним код завершения и выйдем из программы.


Но перед этим вспомним, что команда запуска сервера тоже была блокирующая, поэтому её теперь нужно выполнять асинхронно, т.е.: go application.GRPCServer.MustRun().


Собираем всё это вместе и получаем:


// cmd/sso/main.go

import (
    "os"
    "os/signal"
    "syscall"
)

func main() {
    // ...

    go func() {
        application.GRPCServer.MustRun()
    }()

    // Graceful shutdown

    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)

    // Waiting for SIGINT (pkill -2) or SIGTERM
    <-stop

    // initiate graceful shutdown
    application.GRPCServer.Stop() // Assuming GRPCServer has Stop() method for graceful shutdown
    log.Info("Gracefully stopped")
}

Далее предлагаю вам самостоятельно написать аналогичный метод Stop() для sqlite-реализации Storage. Там это делается тоже одной строчкой: s.db.Close(). При этом, конечно же, придется добавить Storage в структуру App основного приложения (internal/app/app.go). При желании, можете обернуть хранилище в отдельное приложение StorageApp — это хороший подход.



Функциональные тесты


Как и в статье / ролике про REST API, мы напишем функциональные тесты, которые будут тестировать наше приложение как чёрную коробку. Это значит, что мы будем честно поднимать приложение, которое не будет подозревать о том, что его тестируют, и будем честно отправлять в него сетевые запросы.


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


Подчеркну, что тесты нужны не только для проверок корректности работы, они также служат удобным способом заставить приложение что-то сделать. Особенно, когда это сложный редкий кейс. Нам это может понадобиться во время разработки, дебага и т.п.


Подобные тесты также порой называют изоляционными, т.к. в случае наличия зависимостей (т.е. других сервисов, с которыми нужно взаимодействовать), они заменяются сетевыми моками. Именно сетевыми, потому наше приложение всё ещё думает, что оно честно работает и честно взаимодействует с соседями.


Ещё одно преимущество подобных тестов в нашем случае — в процессе их написания мы научимся писать gRPC-клиент для нашего сервиса. А после прочтения статьи вы можете использовать их как полигон для своих экспериментов, играясь с параметрами клиента, пытаясь отправлять различные хитрые запросы, воспроизводить интересные кейсы и т.п.


Приступим. Функциональные тесты мы положим в отдельную папку — tests/. Забегая вперед, структура будет выглядеть так:


./tests
├── auth_register_login_test.go ... Тест-кейсы для проверки Login 
├── some_other_case_test.go ....... Какие-то другие кейсы
├── migrations .................... Миграции для тестов
│   └── 1_init_apps.up.sql
└── suite
    └── suite.go .................. Подготовка всего необходимого для тестов

Подробнее:


  • В корне директории tests мы храним сами файлы тестов с конкретными кейсами (у них обязательно должен быть суффикс _test.go)
  • tests/migrations — дополнительные миграции, которые нужны только для тестов. Обычно я их использую для инициализации БД самыми необходимыми данными. Например, здесь мы напишем миграцию для добавления тестового приложения в таблицу apps.
  • tests/suite — здесь мы будем готовить всё, что необходимо каждому тесту. Например, соединение с БД, создание gRPC-клиента и др.

Миграции для тестов


Начнём с самого простого — с миграций, нам понадобится всего одна:


-- tests/migrations/1_init_apps.up.sql

INSERT INTO apps (id, name, secret)  
VALUES (1, 'test', 'test-secret') 
ON CONFLICT DO NOTHING;

Обратите внимание, на строчку ON CONFLICT DO NOTHING — если такая запись уже есть, миграция просто ничего не сделает. Также держим в голове, что указанные id и secret нам скоро понадобятся в тестах.


Обратную миграцию я здесь писать не планирую, т.к. в тестовой базе мне это не нужно.


Выполнять миграции из этой папки будем той же утилитой, но с другими параметрами:


go run ./cmd/migrator --storage-path=./storage/sso.db --migrations-path=./tests/migrations --migrations-table=migrations_test

То есть, мы используем для них отдельную таблицу migrations_test, чтобы эти тестовые миграции были независимы от основных. Также указываем, соответственно, другой путь до файлов миграций.


Suite — подготовка к тесту


Теперь подготовим Suite. Он представляет из себя структуру, в которой содержится всё необходимое для теста:


// tests/suite/suite.go

import (
    "testing"
    "grpc-service-ref/internal/config"
    ssov1 "github.com/GolangLessons/protos/gen/go/sso"
)

type Suite struct {
    *testing.T                  // Потребуется для вызова методов *testing.T
    Cfg        *config.Config   // Конфигурация приложения
    AuthClient ssov1.AuthClient // Клиент для взаимодействия с gRPC-сервером Auth
}

  • *testing.T — объект для управлением тестом, подробнее можно почитать тут
  • Cfg — обычный объект конфига, тот же что используется при запуске приложения из cmd
  • AuthClient — gRPC-клиент нашего Auth-сервера, основной компонент Suit'а, с его помощью будем отправлять запросы в тестируемое приложение

Теперь напишем для него конструктор:


// tests/suite/suite.go

import (
    "testing"
    "context"
)

const configPath

// New creates new test suite.
func New(t *testing.T) (context.Context, *Suite) {
    t.Helper()   // Функция будет восприниматься как вспомогательная для тестов
    t.Parallel() // Разрешаем параллельный запуск тестов

    // Читаем конфиг из файла
    cfg := config.MustLoadPath(configPath)

    // Основной родительский контекст   
    ctx, cancelCtx := context.WithTimeout(context.Background(), cfg.GRPC.Timeout)

    // Когда тесты пройдут, закрываем контекст
    t.Cleanup(func() {
        t.Helper()
        cancelCtx()
    })

    // Адрес нашего gRPC-сервера
    grpcAddress := net.JoinHostPort(grpcHost, strconv.Itoa(cfg.GRPC.Port))

    // Создаем клиент
    cc, err := grpc.DialContext(context.Background(),
        grpcAddress,
        // Используем insecure-коннект для тестов
        grpc.WithTransportCredentials(insecure.NewCredentials())) 
    if err != nil {
        t.Fatalf("grpc server connection failed: %v", err)
    }

    // gRPC-клиент сервера Auth
    authClient := ssov1.NewAuthClient(cc)

    return ctx, &Suite{
        T:          t,
        Cfg:        cfg,
        AuthClient: authClient,
    }
}

t.Helper() — помечает функцию как вспомогательную, это нужно для формирования правильного вывода тестов, особенно при их падении. А именно, что-то сфэйлится внутри такого хелпера, в выводе будет указана родительская функция (но в трейсе текущая функция будет, конечно), что очень удобно при отладке.


И вот тут мы можем снова прочувствовать крутость кодогенерации из Protobuf: всего одной строчкой мы создаём готовый клиент для нашего gRPC-сервера: authClient := ssov1.NewAuthClient(cc). Если раньше вы работали с веб-сервисами без кодогенерации, вам точно должно это понравится ????


Методами этого клиенту будут методы сервиса, описанные в контракте, т.е.: authClient.Login() и authClient.Register()


Тест-кейс HappyPath для метода Login()


Что ж, у нас всё готово для написания первого тест-кейса. Начнём простенького Happy Path сценария авторизации (это когда у нас всё идет четко по плану). Сделаем базовые приседания и наметим план действий:


// tests/auth_register_login_test.go
package tests

import (
    "testing"
    "grpc-service-ref/tests/suite"
)

const (
    appID = 1                 // ID приложения, которое мы создали миграцией
    appSecret = "test-secret" // Секретный ключ приложения
)

func TestRegisterLogin_Login_HappyPath(t *testing.T) {
    ctx, st := suite.New(t) // Создаём Suite

    // TODO: Подготовить данные для тестовых запросов (случайные)

    // TODO: Сделать нужные запросы

    // TODO: Проверить результаты
}

В качестве данных нам понадобится логин и пароль, т.к. мы тестируем метод RPC-метод Login(). При этом нам обязательно надо генерировать их случайным образом. Почему:


  • Я принципиально не очищаю результаты предыдущих тестов, а пользователей с одинаковыми данными у нас быть не может
  • Это повышает качество тестов, т.к. мы не привязываемся к конкретным фиксированным данным, больше шансов поймать какой-нибудь редкий баг

Почему я не очищаю БД после тестов? Потому что таким образом мы также тестируем (пусть и не гарантированно), что приложение корректно себя ведёт при любых состояниях БД — когда там пусто, когда данных много, когда среди данных есть сломанные и т.п. Лишним это точно не будет. Кроме того, всегда удобно иметь под рукой не пустую БД, которая живет своей жизнь и постепенно пополняется данными. Но в GitHub Actions (или, к примеру, в пайплайне GitLab), конечно же, будет каждый раз подниматься свежая БД, поэтому там придется каждый раз запускать миграции.


Для генерации случайных данных я использую библиотеку brianvoe/gofakeit — очень крутая штука, может сгенерировать огромное количество различных видов данных. Давайте установим её:


go get github.com/brianvoe/gofakeit/v6@v6.23.2

Теперь можем сгенерировать случайные email и пароль (напомню, что email служит у нас логином):


const passDefaultLen = 10

email := gofakeit.Email()
pass := gofakeit.Password(true, true, true, true, false, passLen)

Хоть мне и нравится эта библиотека, но функции с такими ужасными сигнатурами я ненавижу. Увы, лучшей библиотеки я пока не нашел, поэтому давайте хотя бы причешем что имеем, например вот так:


pass := randomFakePassword)
// ...

func randomFakePassword() string {
    return gofakeit.Password(true, true, true, true, false, passDefaultLen)
}

Теперь можем добавить всё это в тест и выполнить RPC-запросы:


// tests/auth_register_login_test.go
package tests

import (
    "testing"

    "grpc-service-ref/tests/suite"

    ssov1 "github.com/GolangLessons/protos/gen/go/sso"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/brianvoe/gofakeit/v6"
)

const (
    appID  = 1
    appKey = "test-secret"

    passDefaultLen = 10
)

func TestRegisterLogin_Login_HappyPath(t *testing.T) {
    ctx, st := suite.New(t)

    email := gofakeit.Email()
    pass := randomFakePassword()

    // Сначала зарегистрируем нового пользователя, которого будем логинить
    respReg, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{
        Email:    email,
        Password: pass,
    })
    // Это вспомогательный запрос, поэтому делаем лишь минимальные проверки
    require.NoError(t, err)
    assert.NotEmpty(t, respReg.GetUserId())

    // А это основная проверка
    respLogin, err := st.AuthClient.Login(ctx, &ssov1.LoginRequest{
        Email:    email,
        Password: pass,
        AppId:    appID,
    })
    require.NoError(t, err)

    // Тщательно проверяем результаты:

    // TODO: Доставём из ответа токен авторизации и проверяем его содержимое
}

Наши тесты не используют заранее подготовленные данные (кроме приложения, но мы пока и не планируем делать интерфейс для работы с ними), мы честно имитируем поведение пользователя — он зарегистрировался и пытается залогиниться. Приложение не знает, что это лишь тест, для него это вполне реальные запросы.


Напомню, что в этом тесте мы проверяем работу метода Login(), а самое важное в нём — это токен, который приходит с ответом от Auth-сервера. Это значит, что нам необходимо выполнить те же действия, которые будет совершать клиентское приложение:


  • jwt.Parse() — парсинг и валидация с использованием секретного ключа приложения
  • Проверка всех необходимых полей, которые пришли вместе с токеном: uid, email, app_id

Давайте добавим эти проверки в наш тест:


// tests/auth_register_login_test.go
package tests

import (
    "testing"
    "time"

    "grpc-service-ref/tests/suite"

    ssov1 "github.com/GolangLessons/protos/gen/go/sso"
    "github.com/golang-jwt/jwt/v5"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/brianvoe/gofakeit/v6"
)

func TestRegisterLogin_Login_HappyPath(t *testing.T) {
    // .. Подготовка и тестовые запросы (см. выше) ..
    // ...

    // Получаем токен из ответа
    token := respLogin.GetToken()
    require.NotEmpty(t, token) // Проверяем, что он не пустой

    // Отмечаем время, в которое бы выполнен логин.
    // Это понадобится для проверки TTL токена
    loginTime := time.Now()

    // Парсим и валидируем токен
    tokenParsed, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
        return []byte(appSecret), nil
    })
    // Если ключ окажется невалидным, мы получим соответствующую ошибку
    require.NoError(t, err)

    // Преобразуем к типу jwt.MapClaims, в котором мы сохраняли данные
    claims, ok := tokenParsed.Claims.(jwt.MapClaims)
    require.True(t, ok)

    // Проверяем содержимое токена
    assert.Equal(t, respReg.GetUserId(), int64(claims["uid"].(float64)))
    assert.Equal(t, email, claims["email"].(string))
    assert.Equal(t, appID, int(claims["app_id"].(float64)))

    const deltaSeconds = 1

    // Проверяем, что TTL токена примерно соответствует нашим ожиданиям.
    assert.InDelta(t, loginTime.Add(st.Cfg.TokenTTL).Unix(), claims["exp"].(float64), deltaSeconds)

Время, которое мы отмечаем и сохраняем в переменную loginTime может быть немного неточным, т.к. точное время определяем сервер Auth, а для наших тестов он, как мы помним, черная коробка. То есть, может быть лаг между моментом обработки запроса сервером и получением нами ответа.


Для тестов нас это устроит, но нам придется использовать функцию assert.InDelta для проверки значения exp (expiration time). А именно, мы берём сохраненный выше loginTime, добавляем к нему st.Cfg.TokenTTL, преобразуем это в UnixTimestamp (в котором мы изначально сохраняли), и проверяем, что поле exp соответствует этому значению с точностью до одной секунды.


Запуск тестов


Если у вас свежая чистая БД, то первым делом прогоняем основные миграци:


go run ./cmd/migrator --storage-path=./storage/sso.db --migrations-path=./migrations

Затем тестовые миграции:


go run ./cmd/migrator --storage-path=./storage/sso.db --migrations-path=./tests/migrations --migrations-table=migrations_test

Теперь запускаем приложение:


go run ./cmd/sso --config=./config/local_tests_config.yaml

Я обычно использую для тестов отдельный конфиг-файл, поэтому здесь указано соответствующее имя: local_tests_config.yaml. Но делать так не обязательно.


И наконец можем запустить сами тесты, указав где они находятся:


go test ./tests -count=1 -v

Параметр -count=1 — стандартный способ запустить тесты с игнорированием кэша, а -v добавить больше подробностей в вывод теста.


Если вы всё сделали правильно, вы должны увидеть примерно вот такой результат:


=== RUN   TestRegisterLogin_Login_HappyPath
=== PAUSE TestRegisterLogin_Login_HappyPath
=== CONT  TestRegisterLogin_Login_HappyPath
--- PASS: TestRegisterLogin_Login_HappyPath (0.15s)
PASS
ok      grpc-service-ref/tests  0.663s

Негативные кейсы


Обычно с тестах самыми полезными являются негативные кейсы. То есть, мы пытаемся сломать наше приложение, и проверяем что оно ведёт себя так, как мы ожидаем. Я покажу лишь пару примеров, а дальше советую вам самостоятельно придумать самые изощренные попытки сломать собственное приложение, и написать на них тесты.


Что пользователь может сделать не так? Например, он может попытаться зарегистрироваться несколько раз с одинаковым логином. Наше приложение не должно такое позволять, и должно отвечать правильной ошибкой. И уж тем более, оно не должно падать с паникой, например. Тес может выглядеть, например, так:


func TestRegisterLogin_DuplicatedRegistration(t *testing.T) {
    ctx, st := suite.New(t)

    email := gofakeit.Email()
    pass := randomFakePassword()

    // Первая попытка должна быть успешной
    respReg, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{
        Email:    email,
        Password: pass,
    })
    require.NoError(t, err)
    require.NotEmpty(t, respReg.GetUserId())

    // Вторая попытка - фэил
    respReg, err = st.AuthClient.Register(ctx, &ssov1.RegisterRequest{
        Email:    email,
        Password: pass,
    })
    require.Error(t, err)
    assert.Empty(t, respReg.GetUserId())
    assert.ErrorContains(t, err, "user already exists")
}

Также пользователь может присылать неверные данные на вход. Вариаций подобных кейсов довольно много, а проверки примерно одинаковые, поэтому мы напишем табличные тесты (если не знакомы с ними, очень советую ознакомиться). Начнём с регистрации:


func TestRegister_FailCases(t *testing.T) {
    ctx, st := suite.New(t)

    tests := []struct {
        name        string
        email       string
        password    string
        expectedErr string
    }{
        {
            name:        "Register with Empty Password",
            email:       gofakeit.Email(),
            password:    "",
            expectedErr: "password is required",
        },
        {
            name:        "Register with Empty Email",
            email:       "",
            password:    randomFakePassword(),
            expectedErr: "email is required",
        },
        {
            name:        "Register with Both Empty",
            email:       "",
            password:    "",
            expectedErr: "email is required",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{
                Email:    tt.email,
                Password: tt.password,
            })
            require.Error(t, err)
            require.Contains(t, err.Error(), tt.expectedErr)

        })
    }
}

И аналогично для логина:


func TestLogin_FailCases(t *testing.T) {
    ctx, st := suite.New(t)

    tests := []struct {
        name        string
        email       string
        password    string
        appID       int32
        expectedErr string
    }{
        {
            name:        "Login with Empty Password",
            email:       gofakeit.Email(),
            password:    "",
            appID:       appID,
            expectedErr: "password is required",
        },
        {
            name:        "Login with Empty Email",
            email:       "",
            password:    randomFakePassword(),
            appID:       appID,
            expectedErr: "email is required",
        },
        {
            name:        "Login with Both Empty Email and Password",
            email:       "",
            password:    "",
            appID:       appID,
            expectedErr: "email is required",
        },
        {
            name:        "Login with Non-Matching Password",
            email:       gofakeit.Email(),
            password:    randomFakePassword(),
            appID:       appID,
            expectedErr: "invalid email or password",
        },
        {
            name:        "Login without AppID",
            email:       gofakeit.Email(),
            password:    randomFakePassword(),
            appID:       emptyAppID,
            expectedErr: "app_id is required",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{
                Email:    gofakeit.Email(),
                Password: randomFakePassword(),
            })
            require.NoError(t, err)

            _, err = st.AuthClient.Login(ctx, &ssov1.LoginRequest{
                Email:    tt.email,
                Password: tt.password,
                AppId:    tt.appID,
            })
            require.Error(t, err)
            require.Contains(t, err.Error(), tt.expectedErr)
        })
    }
}


Интеграция с внешним сервисом — URL Shortener


Чаще всего gRPC используют для взаимодействия между сервисами, поэтому мы не можем не затронуть подключение нашего SSO к кому-нибудь внешнему сервису. Делается это довольно просто, поэтому в качестве такого сервиса можете взять любой пет-проект или набросать что-то на коленке.


Я же буду показывать на примере интеграции в свой URL Shortener, о котором у меня уже есть статья и ролик. В будущем я планирую развивать экосистему сервисов в подобных гайдах — будет и более продвинутая интеграция с URL Shortener'ом, подключение Gateway, другие сервисы и, обязательно, научимся всё это дело мониторить (трейсы, метрики, алерты, сбор логов и др). Напоминаю, если не хотите пропустить продолжение, советую подписаться на мой Telegram-канал — там я буду советоваться с вами по формату и содержимому новых гайдов, анонсировать их и т.п.


Итак, для начала нам потребуется ещё одна ручка. Дело в том, что существующие методы (Login / RegisterNewUser) внешнему сервису не сильно нужны при нашей текущей схеме. Напомню, что пользователь (или его клиентское приложение) самостоятельно идёт в SSO за токеном, и с этим токеном уже идет в URL Shortener, который пока просто проверяет содержимое токена, без каких-либо запросов в SSO со своей стороны.


gRPC-метод: IsAdmin()


Для того, чтобы не переусложнять статью, я предлагаю добавить простейшую, но при этом очень полезную, новую ручку: sso.IsAdmin(userID). Она будет сообщать, является ли текущий пользователь админом. Shortener же с её помощью будет решать — позволять ли пользователю редактировать чужие записи.


На какие упрощения мы здесь пойдем:


1. Ручка не будем защищена: то есть, любой желающий сможет узнать, кто является админом.


Как правильно:


  • Настроить между сервисами приватную (внутреннюю) сеть: это делается относительно не сложно, и тот же Selectel (который мы будем использовать далее) умеет это делать "из коробки":
    Настройка приватной сети между сервисами


  • Авторизовывать сервисные запросы. К примеру, добавлять в каждый запрос специальный сервисный JWT-токен. Этот вариант подойдёт, если по каким-то причинам не хотите настраивать сеть.



В любом случае, оба этих варианта выходят за рамки текущей статьи. Вероятно, в будущих статьях я подробно разберу один из этих способов, либо даже оба (напишите в комментариях, если вам это интересно).


2. Пользователь будет админом сразу во всех сервисах: как я писал в самом начале, мы не будем пока заморачиваться со сложной системой ролей, оставим это на будущие гайды :)


3. Отправлять вместо userID весь JWT-токен пользователя: это усложнит любопытным лицам получение информации — им нужно будет украсть настоящий подписанный JWT-токен, а не просто отправить любой userID. Это не самый лучший вариант, но зато он самый простой. Можете реализовать его также самостоятельно, в качестве упражнения.


В техническом плане решение тоже будет упрощённое: мы просто добавим колонку is_admin в уже существующей таблице users. Это не очень хорошо, т.к. данные стоит нормализовать — а именно, вынести роли / права в отдельную таблицу. Представьте, что у вас миллион пользователей, а админов всего 10 — вам придётся хранить миллион значений is_admin, хотя могли бы сделать всего 10 записей в отдельной табличке. Я советую вам сразу же переделать мой вариант на нормализованный — это и хорошее упражнение, и хорошая практика.


Первым делом необходимо добавить новый метод в контракт. Открываем наш репозиторий protos и добавляем:


service Auth {
  // ...
  // IsAdmin checks whether a user is an admin.
  rpc IsAdmin (IsAdminRequest) returns (IsAdminResponse);
}

message IsAdminRequest {
  int64 user_id = 1; // User ID to validate.
}

message IsAdminResponse {
  bool is_admin = 1;  // Indicates whether the user is an admin.
}

Не забудьте перегенерировать go-файлы командой task generate (см. раздел про написание контракта), запушить новую версию и назначить ей новый git-тег.


Затем нужно обновить версию в SSO:


go get github.com/GolangLessons/protos

Также необходимо установить пакеты go-grpc-middleware и сам grpc-go, это делается по аналогии с серверной частью.


Теперь, нам нужно:


  • Написать миграцию для добавления колонки is_admin
  • Добавить метод IsAdmin() в репозиторий
  • Добавить метод IsAdmin() в сервис Auth (внутренний)
  • Добавить gRPC-метод IsAdmin()

Уф… Что поделать, таковы реалии современного сервисостроения :) Это еще не самое страшное, порой приходится конвертировать модель между каждым слоем. От части, поэтому я решил использовать int64 для работы с userID вместо кастомного типа (хотя, последнее было бы лучше).


Идём по порядку, начиная с миграции:


-- migrations/2_add_is_admin_column_to_users_tbl.up.sql
ALTER TABLE users
    ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;

И обратная:


-- migrations/2_add_is_admin_column_to_users_tbl.down.sql
ALTER TABLE users DROP COLUMN is_admin;

Запускаем мигратор:


go run ./cmd/migrator --storage-path=./storage/sso.db --migrations-path=./migrations

Теперь метод для репозитория:


// internal/storage/sqlite/sqlite.go

func (s *Storage) IsAdmin(ctx context.Context, userID int64) (bool, error) {
    const op = "storage.sqlite.IsAdmin"

    stmt, err := s.db.Prepare("SELECT is_admin FROM users WHERE id = ?")
    if err != nil {
        return false, fmt.Errorf("%s: %w", op, err)
    }

    row := stmt.QueryRowContext(ctx, userID)

    var isAdmin bool

    err = row.Scan(&isAdmin)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return false, fmt.Errorf("%s: %w", op, storage.ErrUserNotFound)
        }

        return false, fmt.Errorf("%s: %w", op, err)
    }

    return isAdmin, nil
}

Метод для сервиса Auth (не забываем добавить новый метод в интерфейс UserProvider):


// internal/services/auth/auth.go

type UserProvider interface {
    // ...
    IsAdmin(ctx context.Context, userID int64) (bool, error)
}

// IsAdmin checks if user is admin.
func (a *Auth) IsAdmin(ctx context.Context, userID int64) (bool, error) {
    const op = "Auth.IsAdmin"

    log := a.log.With(
        slog.String("op", op),
        slog.Int64("user_id", userID),
    )

    log.Info("checking if user is admin")

    isAdmin, err := a.usrProvider.IsAdmin(ctx, userID)
    if err != nil {
        return false, fmt.Errorf("%s: %w", op, err)
    }

    log.Info("checked if user is admin", slog.Bool("is_admin", isAdmin))

    return isAdmin, nil
}

И наконец метод для сервера (не забыв добавить новый метод в интерфейс Auth):


// internal/grpc/auth/server.go

type Auth interface {
    // ...
    IsAdmin(ctx context.Context, userID int64) (bool, error)
}

func (s *serverAPI) IsAdmin(
    ctx context.Context,
    in *ssov1.IsAdminRequest,
) (*ssov1.IsAdminResponse, error) {
    if in.UserId == 0 {
        return nil, status.Error(codes.InvalidArgument, "user_id is required")
    }

    isAdmin, err := s.auth.IsAdmin(ctx, in.GetUserId())
    if err != nil {
        if errors.Is(err, storage.ErrUserNotFound) {
            return nil, status.Error(codes.NotFound, "user not found")
        }

        return nil, status.Error(codes.Internal, "failed to check admin status")
    }

    return &ssov1.IsAdminResponse{IsAdmin: isAdmin}, nil
}

Ручку для изменения статуса я тут показывать не буду, но она делается очень просто, по аналогии. Это очередное хорошее упражнение для самостоятельной практики. А пока можно просто задать флаг is_admin в БД вручную.


Создание клиента в стороннем сервисе


Заходим в выбранный пет-проект (в моём случае — URL Shortener) и подключаем актуальную версию protos:


go get github.com/GolangLessons/protos

Теперь напишем клиент для SSO. Здесь мы увидим всю магию автогенерации gRPC — почти всё уже написано за нас, нам остаётся лишь использовать:


// internal/clients/sso/grpc/grpc.go
package grpc

import (
    "context"
    "fmt"
    "log/slog"
    "time"

    ssov1 "github.com/GolangLessons/protos/gen/go/sso"
    grpclog "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
    grpcretry "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/retry"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/credentials/insecure"
)

type Client struct {
    api ssov1.AuthClient
    log *slog.Logger
}

func New(
    ctx context.Context,
    log *slog.Logger,
    addr string,            // Адрес SSO-сервера
    timeout time.Duration,  // Таймаут на выполнение каждой попытки
    retriesCount int,       // Количетсво повторов
) (*Client, error) {
    const op = "grpc.New"

    // Опции для интерсептора grpcretry
    retryOpts := []grpcretry.CallOption{
        grpcretry.WithCodes(codes.NotFound, codes.Aborted, codes.DeadlineExceeded),
        grpcretry.WithMax(uint(retriesCount)),
        grpcretry.WithPerRetryTimeout(timeout),
    }

    // Опции для интерсептора grpclog
    logOpts := []grpclog.Option{
        grpclog.WithLogOnEvents(grpclog.PayloadReceived, grpclog.PayloadSent),
    }

    // Создаём соединение с gRPC-сервером SSO для клиента
    cc, err := grpc.DialContext(ctx, addr,
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithChainUnaryInterceptor(
            grpclog.UnaryClientInterceptor(InterceptorLogger(log), logOpts...),
            grpcretry.UnaryClientInterceptor(retryOpts...),
        ))
    if err != nil {
        return nil, fmt.Errorf("%s: %w", op, err)
    }

    // Создаём gRPC-клиент SSO/Auth
    grpcClient := ssov1.NewAuthClient(cc)

    return &Client{
        api: grpcClient,
    }, nil
}

// InterceptorLogger adapts slog logger to interceptor logger.
// This code is simple enough to be copied and not imported.
func InterceptorLogger(l *slog.Logger) grpclog.Logger {
    return grpclog.LoggerFunc(func(ctx context.Context, lvl grpclog.Level, msg string, fields ...any) {
        l.Log(ctx, slog.Level(lvl), msg, fields...)
    })
}

Самая главная строчка здесь: grpcClient := ssov1.NewAuthClient(cc). Она создаёт gRPC клиент для нужного сервиса, в нашем случае — это SSO, а конкретно — Auth. Напомню, что мы внутри proto-файла сделали группировку методов на отдельные сервисы: Auth и в будущем Permissions, UserInfo и т.п.


К клиенту мы также подключаем два интерсептора — grpclog и grpcretry. Первый будет логировать тело каждого запроса и ответа, а второй будет делать ретраи в случае неудачных запросов.


Осталось лишь написать метод IsAdmin():


// internal/clients/sso/grpc/grpc.go
// ...

func (c *Client) IsAdmin(ctx context.Context, userID int64) (bool, error) {
    const op = "grpc.IsAdmin"

    resp, err := c.api.IsAdmin(ctx, &ssov1.IsAdminRequest{
        UserId: userID,
    })
    if err != nil {
        return false, fmt.Errorf("%s: %w", op, err)
    }

    return resp.IsAdmin, nil
}

Прелесть кодогенерации gRPC в том, что сам API-клиент, который непосредственно выполняет запросы, уже написан за нас, как и все необходимые запросы. Аналогично, модели данных (ssov1.LoginRequest и др.) для запросов / ответов тоже уже сгенерированы, и используются как клиентом, так и сервером.


Отправка запроса из стороннего сервиса


Поскольку URL Shortener — это REST API сервис, авторизацию удобнее всего реализовать в виде middleware (аналог интерсепторов gRPC).


Создадим его здесь: internal/http-server/middleware/auth/auth.go


В случае HTTP-запросов, JWT-токен обычно отправляют в заголовке вида:


Authorization: "Bearer <jwt_token>"

Поэтому, для получения токена напишем


// internal/http-server/middleware/auth/auth.go
package auth

import "strings"

// // extractBearerToken extracts auth token from Authorization header.
func extractBearerToken(r *http.Request) string {
    authHeader := r.Header.Get("Authorization")
    splitToken := strings.Split(authHeader, "Bearer ")
    if len(splitToken) != 2 {
        return ""
    }

    return splitToken[1]
}

Теперь сам middleware:


// internal/http-server/middleware/auth/auth.go

package auth

import (
    "context"
    "errors"
    "log/slog"
    "net/http"
    "strings"

    "url-shortener/internal/lib/jwt"
    "url-shortener/internal/lib/logger/sl"
)

var (
    ErrInvalidToken = errors.New("invalid token")
    ErrFailedIsAdminCheck = errors.New("failed to check if user is admin")
)

// New creates new auth middleware.
func New(
    log *slog.Logger,
    appSecret string,
    permProvider PermissionProvider,
) func(next http.Handler) http.Handler {
    const op = "middleware.auth.New"

    log = log.With(slog.String("op", op))

    // Возвращаем функцию-обработчик
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Получаем JWT-токен из запроса
            tokenStr := extractBearerToken(r)
            if tokenStr == "" {
                // It's ok, if user is not authorized
                next.ServeHTTP(w, r)
                return
            }

            // Парсим и валидируем токен, использая appSecret
            claims, err := jwt.Parse(tokenStr, appSecret)
            if err != nil {
                log.Warn("failed to parse token", sl.Err(err))

                // But if token is invalid, we shouldn't handle request
                ctx := context.WithValue(r.Context(), errorKey, ErrInvalidToken)
                next.ServeHTTP(w, r.WithContext(ctx))

                return
            }

            log.Info("user authorized", slog.Any("claims", claims))

            // Отправляем запрос для проверки, является ли пользователь админов
            isAdmin, err := permProvider.IsAdmin(r.Context(), claims.UID)
            if err != nil {
                log.Error("failed to check if user is admin", sl.Err(err))

                ctx := context.WithValue(r.Context(), errorKey, ErrFailedIsAdminCheck)
                next.ServeHTTP(w, r.WithContext(ctx))

                return
            }

            // Полученны данные сохраняем в контекст,
            // откуда его смогут получить следующие хэндлеры.
            ctx := context.WithValue(r.Context(), uidKey, claims.UID)
            ctx = context.WithValue(r.Context(), isAdminKey, isAdmin)

            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Напомню, что английские комментарии я пишу для читателей кода, а русские — для читателей статьи. Так вот, английские я советую вам оставить в своём коде, т.к. в будущем тут можно запутаться в стиле — "баг или фича?" :)


При желании, можете вынести часть, отвечающую за парсинг JWT-токена, в отдельный пакет и покрыть его тестами. Это тоже очень полезно.


Общая логика у нас следующая:


  • Пытаемся получить токен из запроса
  • Если его нет — ничего страшного, некоторые запросы сервиса открыты для неавторизованных пользователей
  • Если же токен есть, но в нём имеются ошибки, мы передаём соответствующую ошибку в контекст, и дальнейшие решения будут принимать следующие по цепочке хэндлеры
  • Если же всё ок, то мы сохраянем userID и isAdmin в контекст, для следующих хэндлеров

Также давайте здесь же напишем методы, с помощью которых мы будем получать эти данные из контекста:


// internal/http-server/middleware/auth/auth.go
// ...

func UIDFromContext(ctx context.Context) (int64, bool) {
    uid, ok := ctx.Value(uidKey).(int64)
    return uid, ok
}

func ErrorFromContext(ctx context.Context) (error, bool) {
    err, ok := ctx.Value(errorKey).(error)
    return err, ok
}

Дальше этот middleware нужно подключить к HTTP-роутеру сервиса и написать соответствующую логику в хэндлерах запросов. Например, разрешать пользователю редактировать записи только в том случае, если он является автором, либо админом. Это выходит за рамки текущей статьи, но я верю что вы справитесь самостоятельно :)



Настраиваем автоматический деплой — GitHub Actions


Сервис наконец готов, и теперь мы можем задеплоить его на удалённый сервер — мы ведь не планируем его хостить 24/7 на своём рабочем компьютере :)


Я человек ленивый, и мне не хочется каждый раз возиться с деплоем, поэтому я настрою автоматический деплой через GitHub Actions. Каждый раз когда мы что-то поменяем в коде, приложение на сервере будет автоматически обновляться и перезапускаться. Благо, GitHub Actions бесплатный, и очень удобный.


Аренда облачного сервера


Первым делом, нам нужно обеспечить себе сервер, чтобы было куда деплоить. Я буду использовать облачный сервер линейки Shared Line от Selectel. Это решение позволяет использовать все преимущества облака и не переплачивать за неиспользуемые ресурсы, так как можно оплачивать только часть ядра — например, 10, 20 или 50%


Для начала зарегистрируемся в панели управления и создадим новый сервер в разделе Облачная платформа. Затем — настроим его.


Сервису подойдет ОС Ubuntu 22.04 LTS, 2 виртуальных ядра с минимальной границей в 20% процессорного времени, 2 ГБ оперативной памяти, а также 10 ГБ на сетевом диске (базовый HDD).


Сервису подойдет ОС Ubuntu 22.04 LTS, 2 виртуальных ядра с минимальной границей в 20% процессорного времени, 2 ГБ оперативной памяти, а также 10 ГБ на сетевом диске (базовый HDD).


В поле Name указываем имя сервера. Советую указать что-то осмысленное, иначе будете постоянно путаться, если у вас больше одного сервера.


Здесь же сразу добавляем SSH Key — он нам понадобится для деплоя. Пароль не потребуется, но можете его сохранить на всякий случай.


Коротенький ликбез по SSH-ключам


Если вы в этом не разбираетесь, то очень рекомендую изучить вопрос подробнее. Но пока достаточно знать следующее.


Первым делом нам надо сгенерировать ключ — локально, у себя на компьютере. Если у вас Mac или Linux, то это можно сделать следующей командой (с Windows я не дружу, тут вам придется погуглить или обратиться к сообществу):


ssh-keygen -t rsa -b 4096 -C "some comment"

Команда задаст несколько вопросов, можете все их игнорировать, оставляя дефолтные значения. Имеет смысл только указать имя файла ключа и путь до него.


В итоге, вы получите два файла:


  • <path>/<key_name> — приватный ключ
  • <path>/<key_name>.pub — публичный ключ

Содержимое публичного ключа мы передадим серверу — это можно сделать при его создании (см. скриншоты выше). Содержимое приватного ключа будет использоваться GitHub Actions, туда мы его добавим чуть позже.


Настройка GitHub Workflow


Итак, GitHub Actions. Это сервис внутри гитхаба, который позволяет выполнять различные Workflow для проектов, которые там хостятся — например, деплой на разные серверы, прогон тестов и многое другое. Разберемся, как его настроить. 


Для добавления Workflow к своему проекту достаточно добавить yaml файл с его конфигурацией в папку: .github/workflows в корне проекта. Назовём наш файл deploy.yaml.


Он будет состоять из трех общих секций:


  • name — название процесса workflow, которое будет отображаться в разделе Actions,
  • on — условия, при которых будет запускаться workflow,
  • jobs — действия, которые необходимо проделать.

Начнём с первых двух, они самые простые:


# .github/workflows/deploy.yaml

name: Deploy App # Даем осмысленное имя

on:
  workflow_dispatch: # Ручной запуск
    inputs: # Что нужно ввести вручную при запуске
      tag: # Мы будем указывать тег для деплоя
        description: 'Tag to deploy'
        required: true

В такой конфигурации workflow будет запускаться только вручную. Притом нужно будет указать git тег, по которому будем деплоить сервис. Можно было бы сделать намного проще — деплоить при каждом пуше / мерже в основную ветку, но мне такой вариант не нравится, я хочу сам контроллировать это дело.


Далее идет секция jobs. Она состоит из двух вложенных секций — deploy и steps. Начнем с самого простого — deploy:


# .github/workflows/deploy.yaml

# name: ..., on: ...

jobs:
  deploy:
    runs-on: ubuntu-latest # ОС для runner'а
    env: # Вводим переменные, которые будем использовать далее
      HOST: root@<your_ip> # логин / хост сервера, на которые деплоим
      DEPLOY_DIRECTORY: /root/apps/grpc-auth # в какую папку на сервере деплоим
      CONFIG_PATH: /root/apps/grpc-auth/config/prod.yaml # конфиг сервиса на сервере  
      ENV_FILE_PATH: /root/apps/grpc-auth/config.env # env-файл с настройками

Далее идет секция steps — она самая большая и забористая:


# .github/workflows/deploy.yaml

# name: ..., on: ...

jobs:
    # deploy: ...

    steps:
      - name: Checkout repository
        uses: actions/checkout@v2
        with:
          ref: ${{ github.event.inputs.tag }}
      - name: Check if tag exists
        run: |
          git fetch --all --tags
          if ! git tag | grep -q "^${{ github.event.inputs.tag }}$"; then
            echo "error: Tag '${{ github.event.inputs.tag }}' not found"
            exit 1
          fi
      - name: Set up Go
        uses: actions/setup-go@v2
        with:
          go-version: 1.21.2
      - name: Build app
        run: |
          go mod download
          go build -o grpc-auth ./cmd/sso
      - name: Deploy to VM
        run: |
          sudo apt-get install -y ssh rsync
          echo "$DEPLOY_SSH_KEY" > deploy_key.pem
          chmod 600 deploy_key.pem
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "mkdir -p ${{ env.DEPLOY_DIRECTORY }}"
          rsync -avz -e 'ssh -i deploy_key.pem -o StrictHostKeyChecking=no' --exclude='.git' ./ ${{ env.HOST }}:${{ env.DEPLOY_DIRECTORY }}
        env:
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
      - name: Remove old systemd service file
        run: |
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "rm -f /etc/systemd/system/grpc-auth.service"
      - name: List workspace contents
        run: |
          echo "Listing deployment folder contents:"
          ls -la ${{ github.workspace }}/deployment
      - name: Create environment file on server
        run: |
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "touch ${{ env.ENV_FILE_PATH }}"
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "chmod 600 ${{ env.ENV_FILE_PATH }}"
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "echo 'CONFIG_PATH=${{ env.CONFIG_PATH }}' > ${{ env.ENV_FILE_PATH }}"
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "echo 'HTTP_SERVER_PASSWORD=${{ secrets.AUTH_PASS }}' >> ${{ env.ENV_FILE_PATH }}"
      - name: Copy systemd service file
        run: |
          scp -i deploy_key.pem -o StrictHostKeyChecking=no ${{ github.workspace }}/deployment/grpc-auth.service ${{ env.HOST }}:/tmp/grpc-auth.service
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "mv /tmp/grpc-auth.service /etc/systemd/system/grpc-auth.service"
      - name: Start application
        run: |
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "systemctl daemon-reload && systemctl restart grpc-auth.service"

В steps перечислены действия, которые будут выполняться по порядку. Каждый шаг состоит из следующих параметров (некоторые опциональные):


  • name — имя шага, будет выводиться в процессе выполнения workflow, пишем что-то осмысленное;
  • uses — использование внешней команды. Например, uses: actions/setup-go@v2 указывает, что шаг будет использовать действие setup-go, доступное в репозитории actions на GitHub;
  • with — параметры, которые передаются в действие;
  • env — определяет переменные окружения для этого шага;
  • run — выполняемая команда.

Теперь разберем каждый шаг:


  • Checkout repository: клонируем репозиторий в runner
  • Check if tag exists: проверяем, существует ли указанный тег
  • Set up Go: устанавливаем определенную версию Go
  • Build app: Скачиваем зависимости и собираем приложение.
  • Deploy to VM: Загружаем файлы из репозитория на виртуальную машину.
  • Remove old systemd service file: Удаляем старый файл сервиса systemd на сервере.
  • List workspace contents: Выводим содержимое рабочего каталога на runner.
  • Create environment file on server: Создаем файл окружения на сервере.
  • Copy systemd service file: Копируем файл сервиса systemd на сервер.
  • Start application: Перезапускаем приложение на сервере.

Как видите, здесь мы проделываем различные манипуляции с файлом конфига systemd, потому что сервис будет запускаться через эту службу. Это надежней, чем запускать его напрямую. К примеру, если сервис упадет, systemd его перезапустит.


Создадим этот файл внутри проекта — deployment/grpc-auth.service. Содержимое файла:


[Unit]
Description=gRPC Auth
After=network.target

[Service]
User=root
WorkingDirectory=/root/apps/grpc-auth
ExecStart=/root/apps/grpc-auth/grpc-auth
Restart=always
RestartSec=4
StandardOutput=inherit
EnvironmentFile=/root/apps/grpc-auth/config.env

[Install]
WantedBy=multi-user.target

Также в workflow фигурирует файл prod.yaml. Это конфиг, который будет использоваться на сервере, он немного отличается от локального. А именно, я указал в нём другой storage_path:


# config/prod.yaml

env: "prod"
storage_path: "/root/apps/storage/sso.db"
grpc:
  port: 44044
  timeout: 5s
migrations_path: "./migrations"

Поскольку, у нас пока нет RPC-методов для добавления сущностей в таблицу apps, заводить там приложения придется вручную. На данный момент, самый просто вариант — взять ваш локальный файл БД и просто скопировать его на удалённый сервер, а затем указать путь до него. Именно так я и поступлю. Но подчеркиваю — это костыль, а хорошим решением было бы написание ручки grpcAuth.CreateApp(), и подключение новых клиентских сервисов через неё.


Наконец, можно отправить проект на GitHub и добавить в секреты SSH-ключ (DEPLOY_SSH_KEY):


Создание SSH-ключа в секреты GitHub Actions


GitHub, Settings -> Secrets and variables -> Actions. Имена обязательно должны совпадать, т.к. они используются в нашем файле workflow


Деплой в продакшен


Если вы все сделали правильно, то осталось лишь установить текущий тег и нажать кнопку деплоя.


Тег я обычно устанавливаю локально:


git tag v0.0.1 && git push origin v0.0.1

Теперь в проекте на GitHub открываем секцию Actions и в списке Workflow выбираем свой Deploy App:


В проекте на GitHub открываем секцию Actions и в списке Workflow выбираем свой Deploy App


Нажимаем Run workflow и ждем. Если все сработало, вы можете обратиться к сервису по публичному IP: http://<your_ip>:<http-port>/url.


Обратите внимание, что порт должен быть не стандартный 80, а тот, который указан в конфиге сервиса.



Заключение


Разработка gRPC сервиса — обширная тема, и в одной статье сложно подробно объяснить все аспекты. Если вам нравятся такие темы, и в целом разработка на Go, подписывайтесь на мой канал. Напомню, что видеоверсия этого материала доступна по ссылке.


У меня много различных проектов и активностей, связанных с разработкой на Go и не только. Проще всего за моей активностью следить через мой Telegram-канал. Анонсы всех статьей, роликов, подкастов и прочего будут там. Кроме того, я пишу там короткие гайды в более свободном формате.

Комментарии (36)


  1. WondeRu
    18.11.2023 11:18
    +2

    Отличная статья. У меня был свой сервис url shortener в 2006. Пришлось прикрыть: ссылки на порно через него делали добрые посетители)


  1. parallels8948
    18.11.2023 11:18

    Статья действительно большая, но интересная!

    Небольшие замечания:

    • откажитесь от taskfile, makefile имеет значительно меньше ограничений и больше функционала

    • вынесите migrations/ из internal/, это прям ужасно, увидел бы такое на работе, засомневался бы в компетенциях разработчика...

    • убейте slog логгер, он сырой и настолько технологично далек от других oss логеров что не описать, перевезите все на zap, только на zap.


    1. JustSkiv Автор
      18.11.2023 11:18
      +1

      Спасибо, рад что понравилось :)

      • Возможно, makefile гибче, но мне больше нравится формат и синтаксис taskfile, и я пока не упирался ни разу в предел его возможностей

      • Миграции у меня не в internal, а в корне лежат. Похоже, вы ошиблись. Ну или у меня где-то опечатка (где?)

      • Мне slog очень понравился, и в него можно обернуть и тот же zap при желании. В видео я объяснял - slog хорош тем, что он универсален. Если использовать zap, то будет очень сложно в будущем переехать на что-то другое.


    1. franzose
      18.11.2023 11:18

      • откажитесь от taskfile, makefile имеет значительно меньше ограничений и больше функционала

      Какая-то мода на Taskfile пошла. Я пока так и не понял прикола. Тулза, которую надо ставить отдельно, снова эти YAML-конфиги.


      1. Borz
        18.11.2023 11:18

        а make разве не надо "отдельно ставить"?


      1. xakep666
        18.11.2023 11:18

        У task, в отличие от make, есть огромный плюс - он максимально кроссплатформенный засчет того, что, во-первых, тоже написан на go, а во-вторых - имеет внутри bash интерпретатор, засчет чего скрипты одинаково работают на всех платформах, включая винду.


  1. mikegordan
    18.11.2023 11:18

    А JWT  где передается в хэдерах в Cookie? Если так то у браузеров ограничение на 1024 символа. Изза ограничений размера, JWT  хранит ОЧЕНЬ много оверхэд информации. Не легче просто взять сериализовать JSON и зашифровать? получится тоже самое, но в 100 раз короче и поместить больше полей данных если будут JSON расширяться.


    1. ElectricPigeon
      18.11.2023 11:18

      В https://www.ietf.org/rfc/rfc2109.txt §6.3 говорит, что должно храниться хотя бы 4096 байт. Есть сайт со статистикой (на 2013 год, можно запустить проверку для своего браузера), где показано, что все браузеры примерно так и ограничивают максимальный размер Cookie. http://browsercookielimits.iain.guru/#limits

      Вообще, мне казалось, что чтобы JWT разросся до 4Кб, туда нужно слишком много информации положить.


  1. gena_k
    18.11.2023 11:18

    Подскажите, для этой таблицы/столбца SQLite разве не создаёт сразу уникальный индекс?

    email TEXT NOT NULL UNIQUE,

    Там просто ниже по коду миграции создаётся ещё один (неуникальный) индекс idx_email на этом столбце.


  1. comerc
    18.11.2023 11:18
    +2

    Не нашёл в заметке ссылку на исходники: https://github.com/GolangLessons/sso


    1. JustSkiv Автор
      18.11.2023 11:18

      Спасибо, добавил в статью


  1. comerc
    18.11.2023 11:18
    +1

    "golang.org/x/exp/slog" можно заменить на "log/slog", т.к. в go.mod заявлено go 1.21


    1. JustSkiv Автор
      18.11.2023 11:18

      Так у меня и используется "log/slog"

      golang.org/x/exp/slog я использовал в прошлой статье, тогда еще не было 1.21


      1. comerc
        18.11.2023 11:18

        у меня все ходы записаны


        1. JustSkiv Автор
          18.11.2023 11:18

          Ну так это в исходниках) Где-то недоглядел, да. Копипастил из предыдущего проекта.

          Но в статье такого нет.


  1. comerc
    18.11.2023 11:18
    +1

    почему "ручка"? потому-что handle


  1. comerc
    18.11.2023 11:18

    Рекуррентно заходить в поддиректории оно, конечно, в таком виде не будет — это сделать немного сложнее.

    Просто добавить две звёздочки для подпапок:

    $ protoc -I proto proto/sso/**/*.proto --go_out=./gen/go/ --go_opt=paths=source_relative --go-grpc_out=./gen/go/ --go-grpc_opt=paths=source_relative

    На Маке работает, если запускать команду в терминале.

    Но если запускать команду внутри go_task, то ругается на "Could not make proto path relative: proto/sso/**/*.proto: No such file or directory"

    Возможно, проблема связана с тем, как go_task обрабатывает символы подстановки, такие как **.

    Потому для Taskfile.yaml пришлось применить вот такой вариант:

    - mkdir -p gen/go & find ./proto/sso -name '*.proto' | xargs protoc -I proto --go_out=./gen/go/ --go_opt=paths=source_relative --go-grpc_out=./gen/go/ --go-grpc_opt=paths=source_relative


    1. JustSkiv Автор
      18.11.2023 11:18

      По поводу первого варианта - я тоже думаю, что проблема на стороне task. Но если сделать вот так, то будет работать и через него:

      protoc -I proto proto/**/*.proto --go_out=./gen/go/ --go_opt=paths=source_relative --go-grpc_out=./gen/go/ --go-grpc_opt=paths=source_relative

      Тут я убрал sso из пути, указал общий путь до protos. Такой вариант и в целом правильней, т.к. помимо sso у вас тут могут лежать контракты и для других сервисов.


  1. comerc
    18.11.2023 11:18

    // TODO: инициализировать объект конфига
    // TODO: инициализировать логгер

    Лучше бы наоборот. Иначе мы не увидим в логе ошибки инициализации конфига. Но, тут возможна проблема курицы и яйца, т.к. в конфиге определяем, куда складывать логи. Вывод: нужно воспользоваться переменными окружения для конфигурирования логов.


    1. JustSkiv Автор
      18.11.2023 11:18

      Ошибки могут возникнуть и при работе с переменными окружения - они могут окзаться пустыми и не валидными. Это частично решает проблему, конечно, но на мой взгляд, эти усилия того не стоят.

      Ошибки инициализации конфига в логах нам и не нужны, т.к. наше приложение без конфига вообще не запустится, а такое поведение тоже нужно уметь мониторить.


  1. comerc
    18.11.2023 11:18

    /config/config_local.yaml хорошо бы прописать в .gitignore


    1. JustSkiv Автор
      18.11.2023 11:18

      Если и добавлять такое в игнор, то я бы посоветовал .git/info/exclude, тогда не придется коммитить это вместе с .gitignore

      Иначе каждый разработчик проекта будет коммитить в gitignore свои личные локальные файлы.


  1. comerc
    18.11.2023 11:18

    type My struct{}
    
    func (My) Try() {
    	println("My")
    }
    
    type My2 struct {
    	My
    }
    
    func (My2) Try() {
    	println("My2")
    }
    
    func main() {
    	My2{}.Try() // My2
    }
    


    Понял, что для структуры serverAPI можно перезаписать методы из встроенной UnimplementedAuthServer. Но зачем прослойка в виде интерфейса Auth, можно же сразу реализовывать интерфейс AuthServer внутри services/auth?

    Сервер валидирует и тупо перекладывает данные и ошибки в слой бизнес-логики (services). Но валидация - часть бизнес-логики, а перекладывать данные и ошибки - вообще сомнительное занятие.


    1. JustSkiv Автор
      18.11.2023 11:18

      Интерфейс Auth - это интерфейс для сервисного слоя, не для gRPC-сервера. То есть, это уже бизнес-логика, а не хэндлеры.


  1. comerc
    18.11.2023 11:18

    Интерфейсы UserSaver и UserProvider реализованы в Storage (как и в предыдущей статье), но тогда контекст можно вынести туда, а не таскать его каждый раз в методах SaveUser() и User(). Или я что-то не учитываю?

    ведь кто сказал, что за сохранение и получение пользователей обязана отвечать одна система?

    Нашёл ответ, зачем таскать контексты в каждом методе. Попахивает "предварительной оптимизацией". Даже если предположить, что источники данных для каждого интерфейса могут быть разные, у них вполне себе может быть общий слой Storage (в котором находится базовый контекст).


  1. comerc
    18.11.2023 11:18

    я обычно размещаю их в месте использования, а не рядом с реализацией

    Согласен. Можно легко и убедительно обосновать. Но я бы оставлял в комментариях реализуемых методов, что это реализация такого-то интерфейса. Поможет поддерживать код?


    1. JustSkiv Автор
      18.11.2023 11:18

      Ни разу не встречался с подобными комментариями в рабочих проектах, и проблемой это не становилось. Обычно IDE с этим справляется неплохо.


  1. comerc
    18.11.2023 11:18

    Как применяется claims - не очень понятно, я бы исправил.


  1. comerc
    18.11.2023 11:18

    Текущая реализация метода имеет одну критичную дыру в безопасности — он не защищен от брутфорса (перебора паролей).

    Это внешняя инфраструктурная задача относительно функциональности. Мы же не пихаем в каждый веб-сервис защиту от DDOS-атак. Например, поможет fail2ban


  1. comerc
    18.11.2023 11:18

    Смущает двойная обработка ошибок, например в Login. Зачем их там логировать? Напрашивается отдельный middleware-слой на потоке данных, в котором выполняется централизованное логирование.


  1. comerc
    18.11.2023 11:18

    func (s *Storage) Stop() error {
    	return s.db.Close()
    }

    Номинально присутствует метод для остановки Storage, но он не применяется для Graceful Shutdown.


    1. JustSkiv Автор
      18.11.2023 11:18

      Было бы удобней, если бы вы писали все подобные комментарии в одной ветке, таким образом сгруппировав их. То есть, в ответе к какому-то из своих предыдущих комментариев.

      Иначе среди ваших комментариев затеряются те, что от других людей :)


    1. JustSkiv Автор
      18.11.2023 11:18

      Это я без наезда, если что :) Просто просьба с моей стороны, чтобы проще было ориентироваться в комментариях. Группировка внутри одной ветки помогла бы.

      За поправки к статье я благодарен, в любом случае. У вас они, как правило, по делу, но я не всегда успеваю отвечать.


  1. mavissig
    18.11.2023 11:18
    +1

    Просто хочу сказать Спасибо за такой прекрасный материал, именно таких подробных статей иногда не хватает для изучения нужных технологий, ещё раз спасибо!


  1. mysherocker
    18.11.2023 11:18
    +1

    Ещё бы знать, что авторизация (от authority, буквально: уполномочивание) -- это и есть та самая "работа с пермишнами". А то, что в этой статье называется этим словом -- аутентификация (от authenticity, буквально: проверка подлинности).

    И по этой же причине понятие "авторизоваться" (то есть, уполномочить себя) не имеет смысла.

    И да, использование правильной терминологии -- важно и позволяет сэкономить время при обсуждении. По той причине, что нередко за эти две задачи отвечают разные независимые компоненты. И то, что проблемы с аутентификацией и авторизацией решаются разными способами.


  1. ElectricPigeon
    18.11.2023 11:18

    Спасибо Вам за статью! Хочу поделиться двумя предложениями, которые могли бы улучшить читаемость пары строк кода.

    Вместо `signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)` мне на практике оказалось гораздо удобнее использовать `signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)`. Он не заставляет создавать лишние каналы снаружи) Он не сразу в пакете signal появился, поэтому про него часто не знают.

    Для проверки диапазона времени можно использовать `require.WithinDuration(loginTime.Add(s.Config.TokenTTL), claims.ExpiresAt.Time, 1*time.Second)`. Должно выглядеть "прозрачнее" для читателя.