Как стать автором
Обновить
2662.4
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Разработка REST-серверов на Go. Часть 6: аутентификация

Время на прочтение13 мин
Количество просмотров34K
Автор оригинала: Eli Bendersky
Перед вами — шестой материал из серии статей, посвящённых разработке REST-серверов на Go. Наша сегодняшняя тема — безопасность, а именно — аутентификация. Если бы сервер, разработанный в предыдущих материалах, был бы развёрнут, и к нему мог бы обратиться кто угодно, то у любого, имеющего выход в интернет, был бы полный доступ к его API. Хотя некоторым REST-серверам это вполне подходит, такой сценарий работы с сервером желателен далеко не всегда. Обычно как минимум часть API должна быть защищена таким образом, чтобы доступ к ней могли бы получить лишь аутентифицированные пользователи.




Аутентификация и авторизация


Когда говорят об аутентификации, используя сокращение «auth», речь может идти об одном из двух следующих понятий:

  • Аутентификация (authentication, authn) — предоставления доступа к API только зарегистрированным (известным системе) пользователям.
  • Авторизация (authorization, authz) — предоставление неких возможностей системы пользователям в зависимости от того, какие именно разрешения они имеют на сервере.

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

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

HTTPS/TLS как основа системы аутентификации


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

В случае с REST-серверами, такими, как наш, работа с которыми осуществляется по HTTP, в роли транспортного протокола должен использоваться HTTPS. О том, как оснастить Go-сервер поддержкой HTTPS, я уже кое-что писал.

Реализация базовой схемы аутентификации HTTP через HTTPS


В HTTP уже давно есть базовая схема аутентификации. Самый свежий документ, описывающий её — это RFC 7617. Саму по себе эту схему аутентификации применять категорически не рекомендуется, та как при её использовании имя пользователя и пароль передаются по сети в виде обычного текста (весьма слабо замаскированного кодировкой base64).

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

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

Я, готовясь к написанию этой статьи, прочёл несколько материалов о защите информации и пришёл к выводу о том, что использование базовой схемы аутентификации HTTP через TLS — это безопасно. Есть масса тонкостей, о которых нужно знать при использовании базовой схемы аутентификации в браузере (когда браузер выводит серое окно), но эти тонкости редко имеют отношение к защите REST API. Единственный пример здравой критики этой схемы, обнаруженный мной, заключается в том, что отправка пароля с каждым запросом расширяет окно атаки. Это правда, но альтернативы такому подходу, вроде хранения состояния соединения и использования токенов, мне не кажутся достаточно близкими духу REST-серверов. Предполагается, что REST — это архитектура ПО, при использовании которой сведения о состоянии запросов не хранятся.

Если взглянуть на API больших сервисов, вроде Stack Overflow или GitHub, то можно сказать, что они обычно используют секретные токены, генерируемые при входе в систему. Такие токены включаются в каждый запрос, и они, как правило, не сильно отличаются от паролей. Единственное преимущество токенов перед паролями заключается в том, что токены легче аннулировать. Пользователь может иметь несколько токенов, предназначенных для разных нужд и рассчитанных на различные «уровни доступа». Применение токенов, кроме того, избавляет от необходимости обрабатывать пароли с использованием алгоритма bcrypt, что может положительно сказаться на задержках сервера при ответах на запросы (так как bcrypt, по своей природе, медленный алгоритм).

Если вы собираетесь пользоваться тем, о чём я расскажу, при разработке какого-то важного приложения или API — проконсультируйтесь, пожалуйста, с экспертом по информационной безопасности.

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

Базовая схема аутентификации HTTP, на самом деле, весьма проста. Если на сервер поступает неаутентифицированный HTTP-запрос, сервер отправляет клиенту ответ с заголовком WWW-Authenticate. Клиент после этого может отправить серверу ещё один запрос, содержащий всё, что нужно для аутентификации, воспользовавшись заголовком Authorization.

Взглянем на код. Рассмотрим простой пример Go-сервера с поддержкой HTTPS, в котором доступ к пути secret/ защищён с использованием базовой схемы аутентификации:

func main() {
  addr := flag.String("addr", ":4000", "HTTPS network address")
  certFile := flag.String("certfile", "cert.pem", "certificate PEM file")
  keyFile := flag.String("keyfile", "key.pem", "key PEM file")
  flag.Parse()

  mux := http.NewServeMux()
  mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    if req.URL.Path != "/" {
      http.NotFound(w, req)
      return
    }
    fmt.Fprintf(w, "Proudly served with Go and HTTPS!\n")
  })

  mux.HandleFunc("/secret/", func(w http.ResponseWriter, req *http.Request) {
    user, pass, ok := req.BasicAuth()
    if ok && verifyUserPass(user, pass) {
      fmt.Fprintf(w, "You get to see the secret\n")
    } else {
      w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
      http.Error(w, "Unauthorized", http.StatusUnauthorized)
    }
  })

  srv := &http.Server{
    Addr:    *addr,
    Handler: mux,
    TLSConfig: &tls.Config{
      MinVersion:               tls.VersionTLS13,
      PreferServerCipherSuites: true,
    },
  }

  log.Printf("Starting server on %s", *addr)
  err := srv.ListenAndServeTLS(*certFile, *keyFile)
  log.Fatal(err)
}

Если вы не очень хорошо ориентируетесь в вопросах настройки сертификатов и TLS — взгляните на этот мой материал. Тут я сосредоточусь лишь на обработчике запросов для пути secret/:

mux.HandleFunc("/secret/", func(w http.ResponseWriter, req *http.Request) {
  user, pass, ok := req.BasicAuth()
  if ok && verifyUserPass(user, pass) {
    fmt.Fprintf(w, "You get to see the secret\n")
  } else {
    w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
    http.Error(w, "Unauthorized", http.StatusUnauthorized)
  }
})

В net/http встроена поддержка базовой схемы аутентификации, система сама разбирает соответствующий заголовок запроса. Она извлекает из него имя пользователя и пароль, после чего даёт к ним доступ через метод BasicAuth. Скоро мы подробнее обсудим verifyUserPass, а пока давайте поговорим о том, что делает сервер в ситуации, когда верифицировать пользователя не удаётся. Сервер в такой ситуации отправляет ответ с кодом 401 (Unauthorized). Он устанавливает заголовок этого ответа WWW-Authenticate для того чтобы указать на то, что он использует базовую аутентификацию в области api. Наименование области даётся произвольно, его может выбрать сервер. Предполагается, что это — описание того, какого рода авторизация требуется в том случае, если сервер имеет несколько различных доменов безопасности. Конкретное значение не имеет смысла на данном уровне протокола — тут сервер и клиент полностью друг друга понимают.

Вот — код функции verifyUserPass. Она имитирует работу системы проверки имён и паролей, выполняя проверку с использованием данных двух известных ей пользователей:

var usersPasswords = map[string][]byte{
  "joe":  []byte("$2a$12$aMfFQpGSiPiYkekov7LOsu63pZFaWzmlfm1T8lvG6JFj2Bh4SZPWS"),
  "mary": []byte("$2a$12$l398tX477zeEBP6Se0mAv.ZLR8.LZZehuDgbtw2yoQeMjIyCNCsRW"),
}

// verifyUserPass проверяет, что имя пользователя и пароль соответствуют друг другу,
// сверяясь с нашей "базой данных" userPasswords.
func verifyUserPass(username, password string) bool {
  wantPass, hasUser := usersPasswords[username]
  if !hasUser {
    return false
  }
  if cmperr := bcrypt.CompareHashAndPassword(wantPass, []byte(password)); cmperr == nil {
    return true
  }
  return false
}

Хеш-таблица usersPasswords символизирует некую базу данных, которая может использоваться на реальном сервере. Самое важное, на что тут надо обратить внимание, это — использование пакета bcrypt для хеширования паролей. Ни при каких условиях не храните пароли в виде обычного текста. Для их хранения всегда нужно использовать какой-нибудь механизм хеширования. Это поможет смягчить последствия утечки данных в том случае, если злоумышленники получат доступ к базе данных некоего сервиса. Алгоритм bcrypt — это детально продуманный механизм хеширования паролей, в котором предусмотрено несколько уровней защиты:

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

Пользователь, как разумно будет предположить, регистрируется в нашем сервисе (или получает имя пользователя и пароль как-то иначе). В момент получения пароля вычисляется и сохраняется в базе данных его bcrypt-хеш. Сервер не хранит пароль в виде обычного текста.

Запустим этот сервер на локальной машине:

$ go run /usr/local/go/src/crypto/tls/generate_cert.go --ecdsa-curve P256 --host localhost
2021/05/08 06:51:57 wrote cert.pem
2021/05/08 06:51:57 wrote key.pem

$ go run https-basic-auth-server.go
2021/05/08 06:52:16 Starting server on :4000

Теперь его можно протестировать с помощью curl. Попробуем сначала обратиться к корневому пути — чтобы проверить работоспособность TLS:

$ curl --cacert cert.pem https://localhost:4000/
Proudly served with Go and HTTPS!

Можно попробовать обратиться к пути secret/ без использования аутентификации:

$ curl --cacert cert.pem https://localhost:4000/secret/
Unauthorized

И наконец — выполним запрос по тому же пути, используя учётные данные пользователя joe. Пароль этого пользователя — 1234, ожидается, что в соответствующем заголовке строка joe:1234 будет представлена в кодировке base64:

$ echo -n "joe:1234" | base64
am9lOjEyMzQ=

$ curl --cacert cert.pem -H "Authorization: Basic am9lOjEyMzQ=" https://localhost:4000/secret/
You get to see the secret

Тут показана ручная установка этого заголовка, но curl может настроить его и самостоятельно — если мы передадим этой утилите --user joe:1234.

Всё работает как надо! Вот, для полноты изложения, код Go-клиента, который можно использовать для работы с нашим сервером:

func main() {
  addr := flag.String("addr", "localhost:4000", "HTTPS server address")
  certFile := flag.String("certfile", "cert.pem", "trusted CA certificate")
  user := flag.String("user", "", "username")
  pass := flag.String("pass", "", "password")
  flag.Parse()

  // Читаем сертификат доверенного CA из файла и настраиваем клиент с использованием TLS
  // так, чтобы он доверял бы серверу, подписанному этим сертификатом.
  cert, err := os.ReadFile(*certFile)
  if err != nil {
    log.Fatal(err)
  }
  certPool := x509.NewCertPool()
  if ok := certPool.AppendCertsFromPEM(cert); !ok {
    log.Fatalf("unable to parse cert from %s", *certFile)
  }

  client := &http.Client{
    Transport: &http.Transport{
      TLSClientConfig: &tls.Config{
        RootCAs: certPool,
      },
    },
  }

  // Настраиваем работу HTTPS-запросов с использованием базовой аутентификации.
  req, err := http.NewRequest(http.MethodGet, "https://"+*addr, nil)
  if err != nil {
    log.Fatal(err)
  }
  req.SetBasicAuth(*user, *pass)

  resp, err := client.Do(req)
  if err != nil {
    log.Fatal(err)
  }
  defer resp.Body.Close()

  html, err := io.ReadAll(resp.Body)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Println("HTTP Status:", resp.Status)
  fmt.Println("Response body:", string(html))
}

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

$ go run https-basic-auth-client.go -user joe -pass 1234 -addr localhost:4000/secret/
HTTP Status: 200 OK
Response body: You get to see the secret

А если использовать неправильный пароль — доступ нам получить не удастся:

$ go run https-basic-auth-client.go -user joe -pass 1238 -addr localhost:4000/secret/
HTTP Status: 401 Unauthorized
Response body: Unauthorized

Сервер системы управления задачами, использующий HTTPS, и аутентификационное ПО промежуточного уровня, применяемое к отдельным маршрутам


Теперь, когда мы немного освоили защиту REST-серверов с использованием HTTPS и базовой схемы аутентификации, вернёмся к исходному коду сервера и усовершенствуем его, оснастив защитным механизмом.

Полный код сервера можно найти здесь. Я для этого примера выбрал вариант сервера gorilla-middleware из пятой части этой серии статей и оснастил его поддержкой HTTPS и базовой схемы аутентификации. Основные изменения произведены в функции main. Ниже приведён её обновлённый вариант. Изменённые строки выделены.

func main() {
  certFile := flag.String("certfile", "cert.pem", "certificate PEM file")
  keyFile := flag.String("keyfile", "key.pem", "key PEM file")
  flag.Parse()

  router := mux.NewRouter()
  router.StrictSlash(true)
  server := NewTaskServer()

  // Путь "createTask" защищён с помощью ПО промежуточного уровня BasicAuth.
  router.Handle("/task/",
    middleware.BasicAuth(http.HandlerFunc(server.createTaskHandler))).Methods("POST")
  router.HandleFunc("/task/", server.getAllTasksHandler).Methods("GET")
  router.HandleFunc("/task/", server.deleteAllTasksHandler).Methods("DELETE")
  router.HandleFunc("/task/{id:[0-9]+}/", server.getTaskHandler).Methods("GET")
  router.HandleFunc("/task/{id:[0-9]+}/", server.deleteTaskHandler).Methods("DELETE")
  router.HandleFunc("/tag/{tag}/", server.tagHandler).Methods("GET")
  router.HandleFunc("/due/{year:[0-9]+}/{month:[0-9]+}/{day:[0-9]+}/", server.dueHandler).Methods("GET")

  // Настройка ПО промежуточного уровня для логирования и обработки ошибок panic для всех путей.
  router.Use(func(h http.Handler) http.Handler {
    return handlers.LoggingHandler(os.Stdout, h)
  })
  router.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true)))

  addr := "localhost:" + os.Getenv("SERVERPORT")
  srv := &http.Server{
    Addr:    addr,
    Handler: router,
    TLSConfig: &tls.Config{
      MinVersion:               tls.VersionTLS13,
      PreferServerCipherSuites: true,
    },
  }

  log.Printf("Starting server on %s", addr)
  log.Fatal(srv.ListenAndServeTLS(*certFile, *keyFile))
}

Поговорим об изменениях, внесённых в эту функцию:

  • Во-первых — мы добавили сюда флаги для установки сертификата и файлов ключей для TLS.
  • Мы обернули обработчик createTaskHandler, ответственный за создание задач, в middleware.BasicAuth. Ниже мы рассмотрим код этого ПО промежуточного уровня. Тут, кроме того, показана настройка ПО промежуточного уровня для отдельных маршрутов при использовании маршрутизатора gorilla/mux. Несложно сделать так, чтобы аутентификация требовалась бы для доступа ко всем путям, но тут я всего лишь хочу показать защиту отдельного пути.
  • Мы настроили сервер на использование HTTPS.

Вот код ПО промежуточного уровня BasicAuth (этот код очень похож на код аналогичного ПО из фреймворка Gin):

// UserContextKey - это ключ в контексте запроса, который используется для проверки того,
// есть ли в запросе сведения об аутентифицированном пользователе. ПО запишет в этот ключ
// имя пользователя в том случае, если пользователь был нормально аутентифицирован с использованием пароля.
const UserContextKey = "user"

// BasicAuth - это ПО промежуточного уровня, проверяющее запрос на предмет правильного использования в нём 
// механизма базовой аутентификации с применением пары user:password, проверенной authdb.
func BasicAuth(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
    user, pass, ok := req.BasicAuth()
    if ok && authdb.VerifyUserPass(user, pass) {
      newctx := context.WithValue(req.Context(), UserContextKey, user)
      next.ServeHTTP(w, req.WithContext(newctx))
    } else {
      w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
      http.Error(w, "Unauthorized", http.StatusUnauthorized)
    }
  })
}

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

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

Итоги


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

Здесь мы создали защищённую версию нашего сервера, используя HTTPS и базовую схему аутентификации. Эта техника может быть применена к любому из разработанных ранее вариантов сервера, так как при её реализации используется очень мало зависимостей. Фактически, единственной зависимостью, выходящей за пределы стандартной библиотеки Go, является x/crypto/bcrypt. Здесь x/ традиционно считается расширением стандартной библиотеки, поддержкой которого, в основном, занимается команда разработчиков Go.

Здесь, намеренно, продемонстрирован простой подход к защите серверов. Дело в том, что при настройке аутентификации используется множество сложных механизмов и инструментов — сессии, хранение состояния на клиенте (куки-файлы, JWT), хранение состояния на сервере и многое другое. Опыт подсказывает мне, что к REST-серверам применим далеко не весь этот арсенал. В случае с REST-серверами каждый запрос должен быть изолирован от других запросов, а механизм сессий не очень хорошо с этим согласуется. При разработке защищённых REST-серверов подходит базовая схема аутентификации HTTP, хотя возможны и улучшения механизмов аутентификации, основанных на этой схеме. Например, вместо паролей можно использовать токены, что позволит легче управлять сроком действия аутентификационных данных. Ещё можно передать решение задачи аутентификации стороннему сервису (например — с использованием OAuth 2.0).


Как вы организуете аутентификацию на своих REST-серверах?

Теги:
Хабы:
Всего голосов 19: ↑18 и ↓1+27
Комментарии3

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds