Работа с одноразовыми паролями в Go

zaz600
Golang Notes
Published in
7 min readMay 10, 2016

--

После информации о взломе сразу двух учетных записей в мессенджере Telegram при помощи перехвата СМС, используемых для идентификации пользователя, заинтересовался альтернативными вариантами двухфакторной аутентификации (2ФА), когда пользователь получает код не через СМС.

Беглый поиск подсказал про существование способа генерации одноразовых паролей (TOTP) в приложениях Google Authenticator и Яндекс.Ключ.

Подробно о работе механизма генерации одноразовых паролей приложением Яндекс.Ключ можно прочитать в этой статье.

Если же кратко, то после включения 2ФА, например, во Вконтакте и добавления учетной записи в Яндекс.Ключ, процесс входа на сайт выглядит так: вводим логин и пароль, потом ВК просит ввести одноразовый код, который мы смотрим в приложении Яндекс.Ключ. Коды там меняются каждые 30 секунд.

Напишем на Go простое веб-приложение, которое будет аутентифицировать пользователя по логину, паролю и одноразовому коду, сгенерированному в приложении Яндекс.Ключ.

Чего мы не будем делать в программе, чтобы не усложнять её.

  • Проверять возвращаемые функциями ошибки.
  • Делать отдельную страницу для запроса одноразового кода. Будем запрашивать логин, пароль и одноразовый код на одной странице.
  • Защищать формы от CSRF.
  • Делать красивый дизайн, использовать javascript.
  • Использовать https.
  • Использовать базу данных.
  • Генерировать cookie, отслеживать сессии и т.п.

Код программы можно посмотреть на github.

Начало

Начнем с простой заготовки программы, которая в браузере показывает сообщение “hello”.

package mainimport (
“net/http”
)
func main() {
http.HandleFunc(“/”, indexHandlerFunc)
http.ListenAndServe(“:3000”, nil)
}
func indexHandlerFunc(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(“hello”))
}

Здесь все стандартно.

Компилируем программу, запускаем, открываем браузер, вводим адрес http://127.0.0.1:3000.

Исходник можно посмотреть тут.

Страница авторизации

Добавим шаблон главной страницы. Создаем файл templates/index.html

<h1>Please Sign in</h1><form action=”/login/” method=”POST”>
<div>
<label for=”user”>user:</label><br>
<input type=”input” name=”user” id=”user” value=”zaz600">
</div>
<div>
<label for=”password”>password:</label><br>
<input type=”password” name=”password” id=”password” value=””>
</div>
<div><input type=”submit” value=”Sign in”></div>
</form>

Шаблон максимально упрощён. Мы не используем все необходимые теги html.

Подключаем библиотеку для работы с шаблонами и используем его.

import (
“html/template”
“net/http”
)
func indexHandlerFunc(w http.ResponseWriter, r *http.Request) {
//для простоты не обрабатываем ошибки
t, _ := template.ParseFiles(“templates/index.html”)
t.Execute(w, nil)

}

Компилируем. Запускаем. Проверяем.

Теперь надо добавить обработчик запросов, на который будут отправляться данные формы авторизации POST-запросом.

func main() {
http.HandleFunc(“/”, indexHandlerFunc)
http.HandleFunc(“/login/”, loginHandlerFunc)
http.ListenAndServe(“:3000”, nil)
}
func loginHandlerFunc(w http.ResponseWriter, r *http.Request) {
//Обрабатываем только POST-запрос
if r.Method != “POST” {
http.NotFound(w, r)
}
//для простоты не обрабатываем ошибки
r.ParseForm()
user := r.FormValue(“user”)
password := r.FormValue(“password”)
//Проверяем логин и пароль
if !(user == “zaz600” && password == “123”) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
w.Write([]byte(“hello “ + user))
}

Компилируем. Запускаем. Проверяем. После успешной авторизации под пользователем zaz600 с паролем 123 мы должны увидеть следующее:

Исходник можно посмотреть тут.

Подключаем шаблон для настройки 2ФА

В конец шаблона templates/index.html добавляем ссылку для перехода на страницу, где будет отображаться QR-код, с помощью которого аккаунт можно добавить в Яндекс.Ключ.

...
<div><input type="submit" value="Sign in"></div>
</form>
<div>
<a href=”/2fa/”>Enable 2FA</a>
</div>

Создаем новый шаблон templates/2fa.html

<h1>Настройка 2ФА</h1><p>Отсканируйте код в приложении Яндекс.Ключ</p><div>
<img src=”/qr.png”>
</div>
<p>Введите код из Яндекс.Ключ</p>
<form action=”/verify2fa/” method=”POST”>
<div>
<input type=”input” name=”passcode” id=”passcode”>
</div>
<div><input type=”submit” value=”Verify”></div>
</form>
<div>
<a href=”/”>На главную</a>
</div>

Подключаем новый шаблон.

func main() {
http.HandleFunc(“/”, indexHandlerFunc)
http.HandleFunc(“/login/”, loginHandlerFunc)
http.HandleFunc(“/2fa/”, setup2FAHandlerFunc)
http.ListenAndServe(“:3000”, nil)
}
...//Отображает страницу с QR-кодом
func setup2FAHandlerFunc(w http.ResponseWriter, r *http.Request) {
//для простоты не обрабатываем ошибки
t, _ := template.ParseFiles(“templates/2fa.html”)
t.Execute(w, nil)
}

Компилируем, запускаем, проверяем.

QR-код отсутствует, так как мы его еще не сгенерировали.

Исходник можно посмотреть тут.

Генерируем QR-код

Для работы с одноразовыми паролями будем использовать библиотеку https://github.com/pquerna/otp. Скачиваем её.

go get github.com/pquerna/otp

Генерируем картинку с кодом настройки.

import (
“html/template”
“net/http”
“github.com/pquerna/otp”
“github.com/pquerna/otp/totp”

)
//тут будем хранить TOTP для одного пользователя
var key *otp.Key
func main() {
//Настраиваем TOTP
//для каждого пользователя TOTP ключ должен быть уникальным
//В нашей программе ключ будет разный с каждым запуском (!)
var err error
key, err = totp.Generate(totp.GenerateOpts{
Issuer: “Example.com”,
AccountName: “
zaz600@example.com”,
})

if err != nil {
panic(err)
}
http.HandleFunc(“/”, indexHandlerFunc)
http.HandleFunc(“/login/”, loginHandlerFunc)
http.HandleFunc(“/2fa/”, setup2FAHandlerFunc)
http.HandleFunc(“/qr.png”, genQRCodeHandlerFunc)
http.ListenAndServe(“:3000”, nil)

...
//Генерирует QR-код для добавления аккаунта в Яндекс.Ключ/Google.Authentificator
func genQRCodeHandlerFunc(w http.ResponseWriter, r *http.Request) {
// Convert TOTP key into a PNG
var buf bytes.Buffer
img, err := key.Image(200, 200)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
//для простоты не обрабатываем ошибки
png.Encode(&buf, img)
w.Header().Set(“Content-Type”, “image/png”)
w.Write(buf.Bytes())
}

Компилируем, запускаем, проверяем.

Исходник можно посмотреть тут.

Проверяем настройку 2ФА

После того, как QR-код был отсканирован и добавлен в Яндекс.Ключ, тот начнет генерировать одноразовые пароли каждые 30 секунд.

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

Для завершения настройки вводим код и нажимаем кнопку Verify. Напишем обработчик, проверяющий этот код.

func main() {

http.HandleFunc(“/qr.png”, genQRCodeHandlerFunc)
http.HandleFunc(“/verify2fa/”, verifi2faHandlerFunc)
http.ListenAndServe(“:3000”, nil)
}
func verifi2faHandlerFunc(w http.ResponseWriter, r *http.Request) {
//Обрабатываем только POST-запрос
if r.Method != “POST” {
http.NotFound(w, r)
}
//для простоты не обрабатываем ошибки
r.ParseForm()
passcode := r.FormValue(“passcode”)
valid := totp.Validate(passcode, key.Secret())
if !valid {
http.Error(w, “Неверный код”, http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`2ФА успешно настроена.`))
//далее нам надо сохранить в базе key.Secret() пользователя
//чтобы позднее верифицировать его одноразовые коды по этому секрету

}

Компилируем. Запускаем. Сканируем QR-код, вводим одноразовый пароль и жмем Verify.

Исходник можно посмотреть тут.

Проверяем одноразовые пароли

Теперь добавим проверку одноразового кода при аутентификации на главной странице.

Добавляем возможность ввода кода при авторизации в шаблон templates/index.html

...
<div>
<label for=”password”>password:</label><br>
<input type=”password” name=”password” id=”password” value=””>
</div>
<div>
<label for=”passcode”>passcode:</label><br>
<input type=”input” name=”passcode” id=”passcode”>
</div>
...

И проверяем одноразовый пароль.

func loginHandlerFunc(w http.ResponseWriter, r *http.Request) {
...
user := r.FormValue(“user”)
password := r.FormValue(“password”)
passcode := r.FormValue(“passcode”)
//Проверяем логин и пароль
if !(user == “zaz600” && password == “123”) {
w.Header().Set(“Content-Type”, “text/html; charset=utf-8”)
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`Неверный логин или пароль <a href=”/”>На главную </a>`))
return
}
valid := totp.Validate(passcode, key.Secret())
if !valid {
w.Header().Set(“Content-Type”, “text/html; charset=utf-8”)
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`Неверный код <a href=”/”>На главную </a>`))
return
}
w.Write([]byte(“hello “ + user))
}

Компилируем. Запускаем. Добавляем аккаунт в Яндекс.Ключ, вводим код. Переходим на главную страницу. Вводим логин (zaz600), пользовательский пароль(123) и одноразовый пароль из Яндекс.Ключа. Если всё верно, то увидим приветствие.

Исходник можно посмотреть тут.

Заключение

Различные сервисы используют одноразовые пароли по-разному. Одни требуют ввести логин, пользовательский пароль, а затем одноразовый код (Вконтакте). Другие требуют ввести логин и сразу одноразовый пароль, как делает Яндекс. Тут надо понимать, что Яндекс в своем Ключе генерируемые одноразовые пароли защищает дополнительным ПИН-кодом, без ввода которого невозможно получить одноразовый пароль.

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

  1. Импортировать библиотеку github.com/pquerna/otp
  2. Сгенерировать TOTP ключ для пользователя. key,_ := totp.Generate(…).
  3. Отобразить пользователю Секретный код ключа и QR-код. User. key.Secret() и key.Image(…).
  4. Проверить, что пользователь успешно настроил использование одноразовых паролей. totp.Validate(…).
  5. Сохранить в базе/файле key.Secret()
  6. Сгенерировать пользователю аварийные коды восстановления (см. ниже)

Проверка одноразовых кодов

  1. Проверить логин и пароль пользователя как обычно.
  2. Если у пользователя настроена 2ФА через TOTP, запросить одноразовый код.
  3. Загрузить TOTP Secret из базы.
  4. Проверить код, который ввел пользователь totp.Validate(…)

Аварийные коды

Если пользователи потеряют доступ к устройству генерации TOTP, они больше не будут иметь доступ к своей учетной записи в вашей системе. Поскольку TOTP часто бывают настроены на мобильных устройствах, которые могут быть потеряны, украдены или повреждены, это проблема возникает часто. По этой причине многие сайты предоставляют своим пользователям “резервные коды” или “коды восстановления”. Это набор одноразовых временных кодов, которые могут быть использованы вместо TOTP. Они могут быть сгенерированы каждому пользователю, который перешел на 2ФА, случайным образом из букв и цифр и должны храниться в базе.

Надёжность

Из википедии:

TOTP достаточно устойчив к криптографическим атакам, однако вероятности взлома есть, например возможен такой вариант атаки «человек посередине»:
Прослушивая трафик клиента, злоумышленник может перехватить посланный логин и одноразовый пароль (или хеш от него). Затем ему достаточно блокировать компьютер «жертвы» и отправить аутентификационные данные от собственного имени. Если он успеет это сделать за промежуток времени X, то ему удастся получить доступ. Именно поэтому X стоит делать небольшим.
Также существует уязвимость связанная с синхронизацией таймеров сервера и клиента, так как существует риск рассинхронизации информации о времени на сервере и в программном и/или аппаратном обеспечении пользователя. Поскольку TOTP использует в качестве параметра время, то при не совпадении значений все попытки пользователя на аутентификацию завершатся неудачей. В этом случае ложный допуск чужого также будет невозможен. Стоит отметить что вероятность такой ситуации крайне мала

Вопросы

Что для меня осталось непонятным.

  1. В каком виде сохранять секрет пользователя? В открытом? Тогда это то же самое, что хранить пароль открыто. В виде хеша? Тогда как его обратить, чтобы передать в totp.Validate ?
  2. Тот же вопрос про аварийные коды: в каком виде их хранить?

--

--