Shanraq.org Shanraq.org
Права: кто что может менять
IT

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 ведёт на ваш сайт: адрес пришёл снаружи, а значит, ему нельзя верить.

Карта урока

Карта урока: вошёл, нашли, его ли

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему спрятанная кнопка не защищает статью?
  2. Чем 403 отличается от 401 и когда вместо 403 отвечают 404?
  3. Почему mayEdit возвращает ошибку, а не true/false?

Задание

Обязательное. Добавьте в блог права. Миграция добавляет статьям author_id и проставляет его существующим статьям. Форма создаёт статью от имени вошедшего. Правка и удаление проверяют хозяина: гостя отправляют на вход, чужому — 403. Кнопку «править» по-прежнему видит только хозяин — но теперь это удобство, а не защита.

Всё это сделано в step-23 — сверьтесь после того, как напишете сами.

По желанию.

  • Уберите проверку из обработчика, оставив её только в шаблоне, и повторите правку чужой статьи через curl. Верните проверку обратно.
  • Добавьте next к странице входа и проверьте, что после входа человек попадает туда, куда шёл.
  • Сделайте так, чтобы чужой черновик отвечал 404, а не 403, и объясните, почему для черновика это правильнее.

Куда это встанет в блоге

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

Долги. Ролей нет — только «хозяин или нет». Ограничения на попытки входа тоже нет. И формы пока можно отправить с чужого сайта: это дыра CSRF, и следующий урок именно про неё.

Ответы

Показать ответы
  1. Потому что кнопка живёт в браузере, а запрос можно отправить без неё — из адресной строки, из консоли или из curl. Шаблон прячет кнопку для удобства, а решает обработчик.
  2. 401 означает «представьтесь» — человек не опознан; 403 означает «опознан, но нельзя». В браузерных приложениях вместо 401 обычно отправляют на страницу входа. 404 вместо 403 отвечают тогда, когда само существование объекта — тайна: чужие черновики, личные сообщения.
  3. Потому что bool не различает причин, а обработчику нужно ответить по-разному: гостя отправить на вход, чужому дать 403, на несуществующее — 404. Ошибка несёт причину, true/false — нет.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

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

Чтобы проверить, нужно войти. Войти

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

Пока нет комментариев. Будьте первым.