Go: с нуля до своего блога Урок 38 из 50
Права: кто что может менять
Тридцать восьмой урок курса по Go. Спрятать кнопку — не защита: измерено, как чужой человек правит вашу статью прямым запросом, пока кнопки он не видит. Проверка живёт в обработчике и в одном месте, а отказы отвечают по-разному: гостю — на вход, чужому — 403, несуществующему — 404.
Зачем это нужно
В прошлом уроке блог научился узнавать вошедшего. Но узнать — половина дела: осталось решить, что этому человеку можно.
Разница между двумя словами стоит того, чтобы её запомнить. Аутентификация отвечает на вопрос «кто ты» — это вход по почте и паролю. Авторизация отвечает на другой: «что тебе можно» — вот она и есть сегодняшний урок.
Правило в блоге простое: читать может кто угодно, править и удалять — только тот, кто написал.
Сразу целиком
Новая папка, go mod init sabaq35. Программа поднимает сервер дважды: сначала так, как делают сначала, потом как надо.
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
_ "modernc.org/sqlite"
)
// ErrNotAllowed — человек вошёл, но эта статья не его.
var ErrNotAllowed = errors.New("это не ваша статья")
func main() {
os.Remove("blog.db")
db := open()
defer db.Close()
fmt.Println("== кнопки не видно, проверки нет")
show(db, false)
fmt.Println()
fmt.Println("== проверка в обработчике")
show(db, true)
}
func show(db *sql.DB, guarded bool) {
reset(db)
srv := httptest.NewServer(routes(db, guarded))
defer srv.Close()
fmt.Println("хозяин видит кнопку: ", hasButton(srv.URL, "aigul"))
fmt.Println("чужой видит кнопку: ", hasButton(srv.URL, "bota"))
fmt.Println("правит хозяин: ", edit(srv.URL, "aigul", "Степь: хозяин поправил"))
fmt.Println("правит чужой: ", edit(srv.URL, "bota", "Степь: чужой поправил"))
fmt.Println("правит гость: ", edit(srv.URL, "", "Степь: гость поправил"))
fmt.Println("в базе после всего: ", title(db, "step"))
}
func routes(db *sql.DB, guarded bool) http.Handler {
mux := http.NewServeMux()
// Страница статьи: кнопку «править» видит только хозяин.
mux.HandleFunc("GET /read/{slug}", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
owner, err := ownerOf(db, slug)
if err != nil {
http.NotFound(w, r)
return
}
fmt.Fprintf(w, "<h1>%s</h1>", title(db, slug))
if who(r) == owner {
fmt.Fprint(w, `<a href="/edit/`+slug+`">править</a>`)
}
})
mux.HandleFunc("POST /edit/{slug}", func(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if guarded {
if err := mayEdit(db, who(r), slug); err != nil {
answer(w, err)
return
}
}
if _, err := db.Exec(`update articles set title = ? where slug = ?`,
r.FormValue("title"), slug); err != nil {
http.Error(w, "не вышло", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusSeeOther)
})
return mux
}
// mayEdit — единственное место, где решается, кому что можно. Когда таких
// мест два, они однажды разойдутся.
func mayEdit(db *sql.DB, user, slug string) error {
if user == "" {
return ErrNoUser
}
owner, err := ownerOf(db, slug)
if err != nil {
return err
}
if owner != user {
return ErrNotAllowed
}
return nil
}
// answer превращает ошибку прав в ответ HTTP. Гостя отправляют входить,
// чужому отвечают отказом, а несуществующее остаётся несуществующим.
func answer(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNoUser):
w.Header().Set("Location", "/login")
w.WriteHeader(http.StatusSeeOther)
case errors.Is(err, ErrNotAllowed):
http.Error(w, "это не ваша статья", http.StatusForbidden)
default:
http.Error(w, "не нашлось", http.StatusNotFound)
}
}
func ownerOf(db *sql.DB, slug string) (string, error) {
var owner string
err := db.QueryRow(`select u.name from articles a
join users u on u.id = a.author_id
where a.slug = ?`, slug).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNoArticle
}
if err != nil {
log.Fatal(err)
}
return owner, nil
}
Помощники — во втором файле, helpers.go: он заводит двух человек, статью с хозяином и отправляет запросы от разных людей.
Вывод:
== кнопки не видно, проверки нет
хозяин видит кнопку: да
чужой видит кнопку: нет
правит хозяин: 303
правит чужой: 303
правит гость: 303
в базе после всего: Степь: гость поправил
== проверка в обработчике
хозяин видит кнопку: да
чужой видит кнопку: нет
правит хозяин: 303
правит чужой: 403 это не ваша статья
правит гость: 303 → /login
в базе после всего: Степь: хозяин поправил
Разбор
Спрятать кнопку — не защита
Смотрите на первую половину вывода. Кнопку «править» чужой человек не видит — шаблон честно её не показал. И тем не менее статью поправил гость, у которого нет даже входа.
Потому что кнопка — это подсказка браузеру, а не замок. Запрос можно отправить и без неё: из адресной строки, из консоли браузера, из curl в одну строку. Ничего взламывать при этом не нужно.
Образ. Табличка «посторонним вход воспрещён» на двери без замка. Она честно предупреждает, и она не удержит никого, кто решит войти.
У этой ошибки есть имя — небезопасная прямая ссылка на объект: адрес известен, проверки нет, и всё чужое доступно тому, кто просто подставит чужой номер. В списке самых частых дыр она держится годами.
Правило: всё, что решает шаблон, должен решать и обработчик. Шаблон прячет кнопку ради удобства, обработчик проверяет право ради безопасности.
Проверка живёт в одном месте
Во второй половине вывода появляется mayEdit, и результат другой: чужой получает отказ, а в базе остаётся правка хозяина.
Важно не только то, что проверка есть, но и то, что она одна:
func mayEdit(db *sql.DB, user, slug string) error {
if user == "" {
return ErrNoUser
}
owner, err := ownerOf(db, slug)
if err != nil {
return err
}
if owner != user {
return ErrNotAllowed
}
return nil
}
Когда таких мест два — в правке и в удалении, — они однажды разойдутся: в одном добавят проверку на редактора, в другом забудут. Одна функция, которую зовут отовсюду, от этого спасает.
Заметьте, что она возвращает: не bool, а ошибку. true и false не различают «войдите» и «это не ваше», а обработчику различать надо.
Три разных отказа
answer превращает ошибку в ответ, и все три случая разные:
- гостю — 303 на
/login. Он не сделал ничего плохого, ему просто нужно войти. В выводе видно, куда именно его отправили; - чужому — 403. Он вошёл, мы знаем кто он, и ответ означает «вы опознаны, и вам сюда нельзя». Просить его войти снова бессмысленно;
- несуществующему — 404. Статьи нет, и права тут ни при чём.
Разница между 401 и 403 путается чаще всего. 401 Unauthorized — «представьтесь», хотя по названию кажется наоборот; 403 Forbidden — «представились, но нельзя». В браузерных приложениях вместо 401 обычно отправляют на страницу входа, как здесь.
Есть и четвёртый подход, который стоит знать: на чужое отвечать 404 вместо 403. Тогда посторонний не узнает даже, что такая статья существует. Для блога это перебор — читатель и так видит все статьи, — а для чужих черновиков или личных сообщений это правильный выбор.
Проверять надо после того, как нашли
Порядок в mayEdit не случаен: сначала «вошёл ли», потом «есть ли статья», и только потом «его ли она».
Если поменять местами два последних шага, получится странность: на чужую несуществующую статью придёт 403 вместо 404, и по этому можно выяснять, какие адреса заняты. Мелочь, но именно из таких мелочей собирают картину.
Кто хозяин, знает база, а не форма
ownerOf спрашивает у базы. Это единственный правильный источник: любые данные, пришедшие с запросом, могут быть подделаны — мы это уже видели с печеньем.
В схеме связь одна: у статьи есть author_id, ссылающийся на users. Проверка прав — обычный join, и права в блоге не требуют ни отдельной таблицы, ни хитрой системы.
Когда ролей станет больше — редактор, модератор, — появится и таблица ролей, и проверка вида «хозяин или редактор». Но начинать с неё не нужно: сложность стоит добавлять тогда, когда без неё уже нельзя.
Обёртка для страниц, куда гостю нельзя
Проверять вход в каждом обработчике — та же беда, что и с проверкой прав в двух местах. Для этого есть обёртки из урока про middleware:
func requireLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if who(r) == "" {
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.Path), http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
Обёртка отвечает на вопрос «вошёл ли вообще», а mayEdit — на вопрос «его ли это». Первое одинаково для всех закрытых страниц, второе зависит от статьи, поэтому и живут они порознь.
next в ссылке — небольшая вежливость: после входа человека возвращают туда, куда он шёл, а не на главную. Только проверьте потом, что next ведёт на ваш сайт: адрес пришёл снаружи, а значит, ему нельзя верить.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему спрятанная кнопка не защищает статью?
- Чем 403 отличается от 401 и когда вместо 403 отвечают 404?
- Почему
mayEditвозвращает ошибку, а неtrue/false?
Задание
Обязательное. Добавьте в блог права. Миграция добавляет статьям author_id и проставляет его существующим статьям. Форма создаёт статью от имени вошедшего. Правка и удаление проверяют хозяина: гостя отправляют на вход, чужому — 403. Кнопку «править» по-прежнему видит только хозяин — но теперь это удобство, а не защита.
Всё это сделано в step-23 — сверьтесь после того, как напишете сами.
По желанию.
- Уберите проверку из обработчика, оставив её только в шаблоне, и повторите правку чужой статьи через
curl. Верните проверку обратно. - Добавьте
nextк странице входа и проверьте, что после входа человек попадает туда, куда шёл. - Сделайте так, чтобы чужой черновик отвечал
404, а не403, и объясните, почему для черновика это правильнее.
Куда это встанет в блоге
С этого урока блог перестаёт быть блогом одного человека: у каждой статьи есть хозяин, и чужое неприкосновенно.
Долги. Ролей нет — только «хозяин или нет». Ограничения на попытки входа тоже нет. И формы пока можно отправить с чужого сайта: это дыра CSRF, и следующий урок именно про неё.
Ответы
Показать ответы
- Потому что кнопка живёт в браузере, а запрос можно отправить без неё — из адресной строки, из консоли или из
curl. Шаблон прячет кнопку для удобства, а решает обработчик. 401означает «представьтесь» — человек не опознан;403означает «опознан, но нельзя». В браузерных приложениях вместо401обычно отправляют на страницу входа.404вместо403отвечают тогда, когда само существование объекта — тайна: чужие черновики, личные сообщения.- Потому что
boolне различает причин, а обработчику нужно ответить по-разному: гостя отправить на вход, чужому дать 403, на несуществующее — 404. Ошибка несёт причину,true/false— нет.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.