вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
@@ -166,16 +166,15 @@ type ReplicaView struct {
|
||||
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
app := r.Group(AppRoot)
|
||||
|
||||
// Слой сессии вешается на **группу корня**, а не на перечень адресов:
|
||||
// перечень рос бы с каждым новым адресом приложения, и забытый в нём адрес
|
||||
// молча перестал бы принимать куку. Собственная поверхность хранилища под
|
||||
// слой не подпадает — часть её защищена ровно тем, что браузер заголовка сам
|
||||
// не шлёт.
|
||||
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
|
||||
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
|
||||
// иначе ушли бы телом библиотеки, мимо единой формы.
|
||||
//
|
||||
// Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и
|
||||
// адрес выдачи файлового токена из пространства хранилища. Область его
|
||||
// действия при этом выводится из **этого же** перечня адресного
|
||||
// пространства — см. `underIdentifiedArea`.
|
||||
app.Bind(OneErrorForm())
|
||||
app.Bind(SessionFromCookie())
|
||||
app.Bind(RequireUser(migrations.UsersCollection))
|
||||
|
||||
app.GET("/me", h.Me)
|
||||
@@ -210,7 +209,9 @@ func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
|
||||
func (h *AppHandler) Me(e *core.RequestEvent) error {
|
||||
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
||||
// человеку, а не сервису.
|
||||
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
|
||||
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
|
||||
// наружу наравне с журналом.
|
||||
return e.JSON(http.StatusOK, MeView{
|
||||
ID: e.Auth.Id,
|
||||
Name: e.Auth.GetString("name"),
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionCookieName — имя куки сессии. Имя нормативно: его смена молча
|
||||
// выкидывает всех вошедших.
|
||||
SessionCookieName = "transcriber_session"
|
||||
// stateCookieName — носитель состояния и проверочного кода PKCE. Живёт
|
||||
// один вход и убирается на возврате, каким бы тот ни был.
|
||||
stateCookieName = "transcriber_login"
|
||||
|
||||
// stateCookieMaxAge — потолок времени на вход у провайдера. Дольше носитель
|
||||
// не нужен, а вечный носитель надолго фиксирует состояние.
|
||||
stateCookieMaxAge = 10 * 60
|
||||
|
||||
// exchangeTimeout — потолок обмена кода у провайдера. Без него молчащий
|
||||
// провайдер держит обработчик возврата открытым неограниченно долго, и «медленный»
|
||||
// становится неотличим от «отказал».
|
||||
exchangeTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// AuthHandler ведёт вход, возврат от провайдера и выход.
|
||||
//
|
||||
// Разбор ответа провайдера остаётся за хранилищем — решение от 2026-08-11.
|
||||
// Обмен кода библиотека наружу не отдаёт: он живёт за её собственным адресом,
|
||||
// поэтому обработчик возврата зовёт этот адрес внутри процесса, через её же
|
||||
// роутер. Цена петли принята решением владельца от 2026-08-12: взамен учётные
|
||||
// записи заводит хранилище и они видны в панели.
|
||||
type AuthHandler struct {
|
||||
app core.App
|
||||
logger *slog.Logger
|
||||
authURL string
|
||||
redirectURL string
|
||||
clientID string
|
||||
secureCookie bool
|
||||
|
||||
// storageMux — роутер хранилища, через который идёт обмен кода. Собирается
|
||||
// один раз: сборка вешает обработчики на само приложение и без
|
||||
// идентификатора, поэтому повторная не заменяет прежние, а добавляет к ним.
|
||||
// Собранный на каждый вход, он копил бы их без предела — и копил бы по
|
||||
// запросу анонима, потому что обмен исполняется раньше обращения к
|
||||
// провайдеру.
|
||||
storageMux http.Handler
|
||||
storageMuxOnce sync.Once
|
||||
storageMuxErr error
|
||||
}
|
||||
|
||||
type AuthHandlerConfig struct {
|
||||
AuthURL string
|
||||
RedirectURL string
|
||||
ClientID string
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
func NewAuthHandler(app core.App, cfg AuthHandlerConfig, logger *slog.Logger) *AuthHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &AuthHandler{
|
||||
app: app,
|
||||
logger: logger,
|
||||
authURL: cfg.AuthURL,
|
||||
redirectURL: cfg.RedirectURL,
|
||||
clientID: cfg.ClientID,
|
||||
secureCookie: cfg.SecureCookie,
|
||||
}
|
||||
}
|
||||
|
||||
// Register вешает адреса входа вне пространства `/api`: оно поделено с
|
||||
// собственными адресами хранилища.
|
||||
func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
// Продление сессии закрывается на всём роутере: адрес приносит хранилище
|
||||
// своим, и перехватить его можно только слоем.
|
||||
r.Bind(BlockSessionRefresh())
|
||||
|
||||
// Адреса вешаются группой корня, а не литералами: корень объявлен единой
|
||||
// точкой адресного пространства, и порознь записанный он разошёлся бы с ней
|
||||
// молча — раздача приложения начала бы отдавать разметку на возврат от
|
||||
// провайдера, без единой ошибки в журнале.
|
||||
auth := r.Group(AuthRoot)
|
||||
|
||||
auth.GET("/login", h.Login)
|
||||
auth.GET("/callback", h.Callback)
|
||||
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
|
||||
// чужой ссылке.
|
||||
//
|
||||
// Слой предъявления нужен и здесь: без него выход не знает, чью сессию
|
||||
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
|
||||
// значение годным. Требования сессии при этом нет: выход без неё убирает
|
||||
// куку и молчит.
|
||||
auth.POST("/logout", h.Logout).Bind(SessionFromCookie())
|
||||
}
|
||||
|
||||
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
|
||||
// PKCE у браузера.
|
||||
func (h *AuthHandler) Login(e *core.RequestEvent) error {
|
||||
state := security.RandomString(32)
|
||||
verifier := security.RandomString(43)
|
||||
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: state + ":" + verifier,
|
||||
Path: "/",
|
||||
MaxAge: stateCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("response_type", "code")
|
||||
query.Set("client_id", h.clientID)
|
||||
query.Set("redirect_uri", h.redirectURL)
|
||||
query.Set("scope", "openid profile email")
|
||||
query.Set("state", state)
|
||||
query.Set("code_challenge", security.S256Challenge(verifier))
|
||||
query.Set("code_challenge_method", "S256")
|
||||
|
||||
separator := "?"
|
||||
if strings.Contains(h.authURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
return e.Redirect(http.StatusFound, h.authURL+separator+query.Encode())
|
||||
}
|
||||
|
||||
// Callback принимает возврат от провайдера, сверяет состояние и меняет код на
|
||||
// сессию средствами хранилища.
|
||||
func (h *AuthHandler) Callback(e *core.RequestEvent) error {
|
||||
// Носитель убирается всегда — и на успехе, и на отказе, — и убирается
|
||||
// **до** записи ответа. Отложенная уборка не работает вовсе: заголовки
|
||||
// фиксируются в момент, когда ответ начинают писать, и позднейшая правка их
|
||||
// карты до браузера не доезжает. Состояние одноразовое ровно этим: пока
|
||||
// носитель жив, переигранный возврат проходит сверку.
|
||||
h.clearStateCookie(e)
|
||||
|
||||
query := e.Request.URL.Query()
|
||||
|
||||
// Всё, что ниже до обмена, — негодный ввод от пришедшего, а не поломка
|
||||
// сервиса: владельцу разбирать нечего, и уровень здесь отладочный. Иначе
|
||||
// обычный отказ человека у провайдера стал бы неотличим от «провайдер лежит».
|
||||
if providerError := query.Get("error"); providerError != "" {
|
||||
h.logger.Debug("Login rejected by provider",
|
||||
"reason", knownProviderError(providerError), "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
state, verifier, err := h.readStateCookie(e)
|
||||
if err != nil {
|
||||
h.logger.Debug("Login state is missing or malformed",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
if query.Get("state") != state {
|
||||
h.logger.Debug("Login state mismatch", "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
code := query.Get("code")
|
||||
if code == "" {
|
||||
h.logger.Debug("Provider returned no code", "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
token, err := h.exchange(e.Request.Context(), code, verifier)
|
||||
if err != nil {
|
||||
// Отказ обмена — уже про сервис и его связь с провайдером, поэтому
|
||||
// уровень выше. Код провайдера в журнал не идёт: он и есть предъявитель
|
||||
// входа.
|
||||
h.logger.Error("Failed to exchange provider code",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
h.setSessionCookie(e, token)
|
||||
|
||||
return e.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// Logout обесценивает выданные учётной записи сессии и убирает куку.
|
||||
//
|
||||
// Порядок обязателен: сперва обесценивание, потом уборка. При обратном порядке
|
||||
// выход, разошедшийся с одновременным входом, оставил бы годную сессию, а
|
||||
// человек был бы уверен, что вышел.
|
||||
func (h *AuthHandler) Logout(e *core.RequestEvent) error {
|
||||
if e.Auth != nil {
|
||||
// Ключ токенов обновляется у свежей записи: между чтением и записью
|
||||
// могла пройти чужая правка, и полное сохранение устаревшей записи
|
||||
// затёрло бы её.
|
||||
record, err := h.app.FindRecordById(e.Auth.Collection().Id, e.Auth.Id)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to load account for logout", "error", err, "transport", "http")
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
|
||||
}
|
||||
|
||||
record.RefreshTokenKey()
|
||||
if err := h.app.Save(record); err != nil {
|
||||
h.logger.Error("Failed to revoke sessions", "error", err, "transport", "http")
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
|
||||
}
|
||||
}
|
||||
|
||||
h.clearSessionCookie(e)
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// exchange зовёт собственный адрес хранилища внутри процесса. По сети запрос не
|
||||
// идёт: роутер поднимается тот же, что обслуживает внешние запросы.
|
||||
func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, exchangeTimeout)
|
||||
defer cancel()
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"provider": pbrepo.ProviderName,
|
||||
"code": code,
|
||||
"codeVerifier": verifier,
|
||||
"redirectURL": h.redirectURL,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build exchange request: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/collections/users/auth-with-oauth2",
|
||||
strings.NewReader(string(body)),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build exchange request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
// Адрес запросу нужен, хотя запрос внутрипроцессный и наружу не идёт.
|
||||
//
|
||||
// Ограничитель частоты хранилища ключует клиента адресом, а у собранного
|
||||
// руками запроса его нет вовсе — и вырожденное значение библиотека отдаёт не
|
||||
// пустой строкой, а литералом. Её собственный страж «пустой ключ пропускаем»
|
||||
// такое значение не ловит, поэтому **все** внутренние обмены кода схлопнулись
|
||||
// бы в один счётчик: третий вход в пределах трёх секунд — чей угодно —
|
||||
// получал бы отказ ограничителя, неотличимый от настоящего отказа провайдера.
|
||||
//
|
||||
// Прежде этого не случалось: ограничитель был выключен целиком. Он включается
|
||||
// вместе с правилом под корнем приложения, и цена названа здесь.
|
||||
request.RemoteAddr = "127.0.0.1:0"
|
||||
|
||||
handler, err := h.storageHandler()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
// Тело ответа наружу не выносится: в нём приезжает описание отказа
|
||||
// провайдера, а оно принадлежит журналу, а не человеку.
|
||||
return "", fmt.Errorf("storage rejected the exchange with code %d", recorder.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
return "", fmt.Errorf("failed to read exchange response: %w", err)
|
||||
}
|
||||
if response.Token == "" {
|
||||
return "", errors.New("exchange response carries no session")
|
||||
}
|
||||
|
||||
return response.Token, nil
|
||||
}
|
||||
|
||||
// knownProviderError приводит причину отказа к перечню известных.
|
||||
//
|
||||
// Значение приходит строкой запроса и целиком задаётся тем, кто её шлёт: без
|
||||
// приведения аноним пишет в журнал что угодно и сколько угодно — предел один,
|
||||
// размер заголовков. Журнал же единственное место, где наблюдаются инварианты о
|
||||
// молчаливой потере задачи, и вытеснять его чужим текстом нельзя.
|
||||
//
|
||||
// Приём тот же, каким расширение записи приводится к перечню форматов.
|
||||
func knownProviderError(value string) string {
|
||||
switch value {
|
||||
case "access_denied", "invalid_request", "invalid_scope", "server_error",
|
||||
"temporarily_unavailable", "unauthorized_client", "unsupported_response_type",
|
||||
"interaction_required", "login_required", "consent_required":
|
||||
return value
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// storageHandler собирает роутер хранилища один раз и отдаёт его всем
|
||||
// последующим обменам.
|
||||
func (h *AuthHandler) storageHandler() (http.Handler, error) {
|
||||
h.storageMuxOnce.Do(func() {
|
||||
router, err := apis.NewRouter(h.app)
|
||||
if err != nil {
|
||||
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
|
||||
return
|
||||
}
|
||||
mux, err := router.BuildMux()
|
||||
if err != nil {
|
||||
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
|
||||
return
|
||||
}
|
||||
h.storageMux = mux
|
||||
})
|
||||
|
||||
return h.storageMux, h.storageMuxErr
|
||||
}
|
||||
|
||||
func (h *AuthHandler) readStateCookie(e *core.RequestEvent) (state, verifier string, err error) {
|
||||
cookie, err := e.Request.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("login state cookie is missing: %w", err)
|
||||
}
|
||||
|
||||
state, verifier, found := strings.Cut(cookie.Value, ":")
|
||||
if !found || state == "" || verifier == "" {
|
||||
return "", "", errors.New("login state cookie is malformed")
|
||||
}
|
||||
|
||||
return state, verifier, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) setSessionCookie(e *core.RequestEvent, token string) {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: pbrepo.SessionDuration,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) clearSessionCookie(e *core.RequestEvent) {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) clearStateCookie(e *core.RequestEvent) {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -16,14 +17,14 @@ import (
|
||||
)
|
||||
|
||||
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
|
||||
// предъявляется сессия, что её прекращает и какие адреса остаются открытыми.
|
||||
// называется пришедший, кому верят и какие адреса остаются открытыми.
|
||||
|
||||
// TestApiRequiresSession — первый критерий приёмки. Запрос без сессии получает
|
||||
// отказ и ничего не заводит, а проба здоровья и метрики остаются открытыми.
|
||||
func TestApiRequiresSession(t *testing.T) {
|
||||
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
|
||||
// отказ и ничего не заводит.
|
||||
func TestApiRequiresIdentity(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
t.Run("приём записи без сессии", func(t *testing.T) {
|
||||
t.Run("приём записи неузнанным", func(t *testing.T) {
|
||||
req := createMultipartRequest(t, "test.mp3", []byte("audio"))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -43,7 +44,7 @@ func TestApiRequiresSession(t *testing.T) {
|
||||
assert.Empty(t, jobs)
|
||||
})
|
||||
|
||||
t.Run("опрос готовности без сессии", func(t *testing.T) {
|
||||
t.Run("карточка записи неузнанному", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -55,9 +56,9 @@ func TestApiRequiresSession(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestUnknownJobIsIndistinguishableWithoutSession: по кодам ответа без сессии не
|
||||
// перебирается список заведённых задач.
|
||||
func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
|
||||
// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
|
||||
// не перебирается список заведённых задач.
|
||||
func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
@@ -78,320 +79,322 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
|
||||
assert.Equal(t, missing.Code, existing.Code)
|
||||
}
|
||||
|
||||
// TestOpenEndpointsStayOpen — вторая сторона границы: проба здоровья и метрики
|
||||
// сессии не требуют. Маршруты вешает `main`, поэтому здесь собирается такой же
|
||||
// роутер с теми же двумя адресами.
|
||||
func TestOpenEndpointsStayOpen(t *testing.T) {
|
||||
// TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**.
|
||||
//
|
||||
// Два запроса подряд с одним значением заголовка: учётная запись заводится
|
||||
// первым и находится вторым, а в хранилище её строка одна.
|
||||
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const login = "newcomer"
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
|
||||
assert.Equal(t, before+1, countAccounts(t, env),
|
||||
"второе обращение завело вторую запись: архив разъехался бы между ними")
|
||||
assert.Equal(t, first.Body.String(), second.Body.String(),
|
||||
"второе обращение попало в другую учётную запись")
|
||||
}
|
||||
|
||||
// TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же
|
||||
// заголовок с недоверенного адреса даёт отказ, а не вход под названным именем.
|
||||
func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set(LoginHeader, "intruder")
|
||||
req.RemoteAddr = untrustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"заголовок с недоверенного адреса завёл учётную запись")
|
||||
}
|
||||
|
||||
// TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**.
|
||||
//
|
||||
// Перечень собственных адресов входа хранилища: каждый отвечает отказом и
|
||||
// учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один
|
||||
// из них работает, узнавание по заголовку обходится двумя запросами.
|
||||
func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const usersRoot = "/api/collections/users"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "завести учётную запись самому",
|
||||
path: usersRoot + "/records",
|
||||
body: `{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`,
|
||||
},
|
||||
{
|
||||
name: "вход по паролю",
|
||||
path: usersRoot + "/auth-with-password",
|
||||
body: `{"identity":"person@example.com","password":"whatever"}`,
|
||||
},
|
||||
{
|
||||
name: "обмен кода у внешнего провайдера",
|
||||
path: usersRoot + "/auth-with-oauth2",
|
||||
body: `{"provider":"oidc","code":"whatever","codeVerifier":"whatever","redirectURL":"https://example.com/"}`,
|
||||
},
|
||||
{
|
||||
name: "вход по одноразовому коду",
|
||||
path: usersRoot + "/auth-with-otp",
|
||||
body: `{"otpId":"whatever","password":"whatever"}`,
|
||||
},
|
||||
{
|
||||
name: "запрос одноразового кода",
|
||||
path: usersRoot + "/request-otp",
|
||||
body: `{"email":"person@example.com"}`,
|
||||
},
|
||||
{
|
||||
name: "восстановление пароля",
|
||||
path: usersRoot + "/request-password-reset",
|
||||
body: `{"email":"person@example.com"}`,
|
||||
},
|
||||
{
|
||||
name: "продление сессии",
|
||||
path: usersRoot + "/auth-refresh",
|
||||
body: `{}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
|
||||
"адрес %s ответил успехом: собственный вход хранилища открыт", c.path)
|
||||
assert.NotContains(t, w.Body.String(), `"token"`,
|
||||
"адрес %s выдал значение доступа", c.path)
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"адрес %s изменил число учётных записей", c.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт.
|
||||
//
|
||||
// Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает
|
||||
// владельцу записи правку собственной строки. Пока узнавание жило под корнем
|
||||
// приложения, до этой поверхности браузер не дотягивался вовсе; теперь она
|
||||
// достижима, и правка своей записи была бы захватом чужого имени: поставил себе
|
||||
// чужой логин — и первое обращение настоящего его владельца попало бы в твою
|
||||
// запись вместе со всем архивом.
|
||||
func TestUserRecordCannotBeEditedFromOutside(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
body := strings.NewReader(`{"` + migrations.ProviderLoginField + `":"victim"}`)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.Id, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "правка своей учётной записи прошла")
|
||||
|
||||
after, err := env.app.FindRecordById(migrations.UsersCollection, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.login, after.GetString(migrations.ProviderLoginField),
|
||||
"ключ учётной записи переписан снаружи")
|
||||
}
|
||||
|
||||
// TestUserRecordsCannotBeListed: перечисление коллекции пользователей закрыто.
|
||||
// Открытое, оно отдавало бы узнанному логины всех остальных — то есть ровно те
|
||||
// значения, которыми довольно назваться.
|
||||
func TestUserRecordsCannotBeListed(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest(http.MethodGet, "/api/collections/users/records", nil), env.login))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
assert.NotContains(t, w.Body.String(), env.login)
|
||||
}
|
||||
|
||||
// TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и
|
||||
// ничего не заводит.
|
||||
//
|
||||
// Пустое значение здесь не крайний случай, а штатное поведение прокси: там, где
|
||||
// он никого не назвал, заголовок приходит пустым. Без этой проверки все
|
||||
// неназванные собрались бы в одну учётную запись с общим архивом.
|
||||
func TestDegenerateHeaderIdentifiesNobody(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
cases := map[string]string{
|
||||
"пустое значение": "",
|
||||
"одни пробелы": " ",
|
||||
"управляющий знак": "ali\x00ce",
|
||||
"длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1),
|
||||
}
|
||||
|
||||
for name, value := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set(LoginHeader, value)
|
||||
req.RemoteAddr = trustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, before, countAccounts(t, env), "вырожденное значение завело учётную запись")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoLoginHeadersIdentifyNobody: запрос с двумя значениями заголовка не
|
||||
// узнаёт никого.
|
||||
//
|
||||
// Прокси, настроенный **добавлять** заголовок вместо замены, оставляет рядом со
|
||||
// своим значением присланное анонимом. Умолчание «берём первое» отдало бы вход
|
||||
// анониму, а «берём последнее» зависело бы от порядка, которым распоряжается не
|
||||
// сервис.
|
||||
func TestTwoLoginHeadersIdentifyNobody(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Add(LoginHeader, "intruder")
|
||||
req.Header.Add(LoginHeader, env.login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, before, countAccounts(t, env))
|
||||
}
|
||||
|
||||
// TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает
|
||||
// заголовок, а протухший узнаванию не мешает.
|
||||
//
|
||||
// Первая половина защищает владельца панели: подмена его учётной записью
|
||||
// пользователя отобрала бы у него панель посреди работы. Вторая — обычного
|
||||
// человека: негодный токен, оставшийся в браузере, не должен запирать его
|
||||
// снаружи.
|
||||
func TestStorageTokenBeatsHeader(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
stranger, strangerLogin := newSecondAccount(t, env.app)
|
||||
token, err := stranger.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("годный токен побеждает", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), stranger.Id,
|
||||
"заголовок победил предъявленный токен")
|
||||
_ = strangerLogin
|
||||
})
|
||||
|
||||
t.Run("протухший токен узнаванию не мешает", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set("Authorization", "not-a-token")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), env.account.Id)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты
|
||||
// неузнанному, а заголовок на них учётной записи не заводит.
|
||||
//
|
||||
// Вторая половина важнее первой: узнавание сужено до области приложения именно
|
||||
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
|
||||
// такой запрос с новым именем — записи в неё.
|
||||
func TestOpenAddressesDoNotIdentify(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.GET("/health", func(e *core.RequestEvent) error {
|
||||
appHandler := NewAppHandler(nil, nil, nil, nil, nil)
|
||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), nil))
|
||||
|
||||
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.GET("/metrics", func(e *core.RequestEvent) error {
|
||||
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
||||
return e.String(http.StatusOK, "# metrics")
|
||||
})
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, path := range []string{"/health", "/metrics"} {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
assert.Equal(t, http.StatusOK, w.Code, "адрес %s обязан отвечать без сессии", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{HealthPath, MetricsPath} {
|
||||
anonymous := httptest.NewRecorder()
|
||||
mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
|
||||
|
||||
// TestSessionSurvivesRestart — второй критерий приёмки. Подпись сессии считается
|
||||
// от секрета коллекции и ключа записи, оба лежат в базе, поэтому выкладка
|
||||
// вошедших не выкидывает.
|
||||
func TestSessionSurvivesRestart(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
before := httptest.NewRecorder()
|
||||
env.serve(before, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||
require.Equal(t, http.StatusCreated, before.Code)
|
||||
|
||||
// Сервер пересоздаётся на том же хранилище — то же, что перезапуск процесса
|
||||
// поверх прежнего каталога данных.
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
env.handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// Прежняя кука прошла проверку: до обработчика дошло, и он ответил про
|
||||
// ненайденную задачу, а не про отсутствующую сессию.
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
// TestLogoutClosesAccess — третий критерий приёмки. Выход обесценивает выданные
|
||||
// сессии, а не только убирает куку.
|
||||
func TestLogoutClosesAccess(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
env.handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
logoutResponse := httptest.NewRecorder()
|
||||
mux.ServeHTTP(logoutResponse, logout)
|
||||
require.Equal(t, http.StatusOK, logoutResponse.Code)
|
||||
|
||||
// Куку выход убирает.
|
||||
assert.Contains(t, logoutResponse.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||
|
||||
// И прежнее значение больше не открывает доступ — этого уборка куки сама по
|
||||
// себе не даёт: унесённое значение работало бы до истечения срока.
|
||||
after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
afterResponse := httptest.NewRecorder()
|
||||
mux.ServeHTTP(afterResponse, after)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
|
||||
}
|
||||
|
||||
// TestLogoutWhenAccountIsGone: учётной записи, которой предъявлена сессия, уже
|
||||
// нет — выход отвечает отказом и не делает вид, что закрыл доступ.
|
||||
func TestLogoutWhenAccountIsGone(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Сессия выдана, а запись удалена — так выглядит гонка выхода с удалением
|
||||
// учётной записи в панели.
|
||||
require.NoError(t, env.app.Delete(env.account))
|
||||
|
||||
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, logout)
|
||||
|
||||
// Записи нет — проверка сессии её не находит, и до обесценивания дело не
|
||||
// доходит: выход отвечает успехом, убрав куку. Доступа при этом всё равно
|
||||
// не осталось, потому что не осталось учётной записи.
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||
|
||||
after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
afterResponse := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(afterResponse, after)
|
||||
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
|
||||
}
|
||||
|
||||
// TestLogoutWithoutSession: выход без сессии убирает куку и молчит.
|
||||
func TestLogoutWithoutSession(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||
}
|
||||
|
||||
// TestLoginRedirectsToProvider: вход уводит к провайдеру и запоминает состояние
|
||||
// у браузера.
|
||||
func TestLoginRedirectsToProvider(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
|
||||
require.Equal(t, http.StatusFound, w.Code)
|
||||
|
||||
location := w.Result().Header.Get("Location")
|
||||
assert.Contains(t, location, "https://auth.example.com/api/oidc/authorization")
|
||||
assert.Contains(t, location, "code_challenge_method=S256")
|
||||
assert.Contains(t, location, "client_id=transcriber")
|
||||
|
||||
// Носитель состояния несёт те же признаки защиты, что и кука сессии.
|
||||
stateCookie := w.Result().Header.Get("Set-Cookie")
|
||||
assert.Contains(t, stateCookie, stateCookieName)
|
||||
assert.Contains(t, stateCookie, "HttpOnly")
|
||||
assert.Contains(t, stateCookie, "Secure")
|
||||
assert.Contains(t, stateCookie, "SameSite=Lax")
|
||||
}
|
||||
|
||||
// TestCallbackRejectsForeignState — возврат с невыданным состоянием сессии не
|
||||
// открывает и учётной записи не заводит.
|
||||
func TestCallbackRejectsForeignState(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
accountsBefore, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cookie *http.Cookie
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "состояния не выдавали вовсе",
|
||||
cookie: nil,
|
||||
query: "?code=whatever&state=foreign",
|
||||
},
|
||||
{
|
||||
name: "состояние не совпало с выданным",
|
||||
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
|
||||
query: "?code=whatever&state=foreign",
|
||||
},
|
||||
{
|
||||
name: "провайдер вернул отказ",
|
||||
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
|
||||
query: "?error=access_denied&state=issued",
|
||||
},
|
||||
withHeader := httptest.NewRecorder()
|
||||
mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby"))
|
||||
assert.Equal(t, http.StatusOK, withHeader.Code,
|
||||
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback"+tc.query, nil)
|
||||
if tc.cookie != nil {
|
||||
req.AddCookie(tc.cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
accountsAfter, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, accountsAfter, len(accountsBefore))
|
||||
|
||||
// Носитель убирается и на отказном возврате: иначе состояние
|
||||
// осталось бы годным для новой попытки.
|
||||
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;")
|
||||
})
|
||||
}
|
||||
records, err := app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records, "обращение к открытому адресу завело учётную запись")
|
||||
}
|
||||
|
||||
// TestHeaderBeatsCookie: предъявленный заголовок побеждает куку.
|
||||
func TestHeaderBeatsCookie(t *testing.T) {
|
||||
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
|
||||
//
|
||||
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
|
||||
// суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся
|
||||
// кука вернула бы и это.
|
||||
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
|
||||
req.Header.Set("Authorization", env.session)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Прошёл заголовок: иначе негодная кука дала бы отказ.
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
|
||||
}
|
||||
|
||||
// TestSelfServiceAccountsAreClosed — то, ради чего задача вообще имеет смысл.
|
||||
// Пока создание записи и вход по паролю открыты, закрытие приёма обходится
|
||||
// двумя запросами.
|
||||
func TestSelfServiceAccountsAreClosed(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
t.Run("завести учётную запись самому нельзя", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, w.Code)
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("вход паролем недоступен", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"identity":"person@example.com","password":"whatever"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/auth-with-password", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccessGrantingValuesAreNotLogged — четвёртый критерий приёмки, расширенный
|
||||
// ревью дизайна: не печатается ничто, что даёт доступ.
|
||||
// TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не
|
||||
// печатается ничто, что даёт доступ.
|
||||
//
|
||||
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
|
||||
// другим ключом, поиск по ключу не разбудил бы.
|
||||
func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
|
||||
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
|
||||
// довольно назваться, чтобы стать этим человеком.
|
||||
func TestIdentityValuesAreNotLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
@@ -401,59 +404,34 @@ func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
|
||||
journal := env.journal.String()
|
||||
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
|
||||
|
||||
assert.NotContains(t, journal, env.session,
|
||||
"значение сессии в журнале: строка стала бы ключом к чужому доступу")
|
||||
assert.NotContains(t, journal, env.login,
|
||||
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
|
||||
assert.NotContains(t, journal, env.account.Email(),
|
||||
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
|
||||
}
|
||||
|
||||
// TestProviderSecretIsNotLogged: секрет клиента не появляется в журнале при
|
||||
// приведении настроек провайдера к конфигу.
|
||||
func TestProviderSecretIsNotLogged(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
|
||||
// виден **адресом пира**, а не значением заголовка.
|
||||
//
|
||||
// Без этой строки владелец, у которого никто не может войти, не отличит своей
|
||||
// поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не
|
||||
// ставит) — а это разные поломки в разных местах.
|
||||
func TestUntrustedPeerIsLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const secret = "super-secret-client-value"
|
||||
const intruder = "intruder-login-value"
|
||||
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: secret,
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set(LoginHeader, intruder)
|
||||
req.RemoteAddr = untrustedPeer
|
||||
|
||||
// Настройка доехала до хранилища — иначе проверка отсутствия секрета в
|
||||
// журнале прошла бы на невыполненной работе.
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, secret, provider.ClientSecret)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
// TestProviderSecretRotationReachesStorage: смена секрета в конфиге доезжает до
|
||||
// хранилища. Положенный однажды шагом схемы, он бы не доехал — применённый шаг
|
||||
// не переписывается.
|
||||
func TestProviderSecretRotationReachesStorage(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
settings := pbrepo.ProviderSettings{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "first-secret",
|
||||
}
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
|
||||
|
||||
settings.ClientSecret = "rotated-secret"
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "rotated-secret", provider.ClientSecret)
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "203.0.113.9", "адреса пира в журнале нет: поломку не отличить")
|
||||
assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
|
||||
@@ -469,10 +447,10 @@ func TestRecordFileIsProtected(t *testing.T) {
|
||||
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
|
||||
}
|
||||
|
||||
// TestRecordFileNeedsSession: ссылка на файл записи без сессии отказывает, а
|
||||
// TestRecordFileNeedsToken: ссылка на файл записи без токена отказывает, а
|
||||
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
|
||||
// ссылке.
|
||||
func TestRecordFileNeedsSession(t *testing.T) {
|
||||
func TestRecordFileNeedsToken(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
@@ -494,9 +472,18 @@ func TestRecordFileNeedsSession(t *testing.T) {
|
||||
// своего существования. До пометки поля защищённым эта же ссылка отдавала
|
||||
// содержимое кому угодно — знание ссылки и было доступом.
|
||||
assert.Equal(t, http.StatusNotFound, anonymous.Code,
|
||||
"ссылка отдала файл без сессии: знание ссылки снова стало доступом")
|
||||
"ссылка отдала файл без токена: знание ссылки снова стало доступом")
|
||||
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
||||
|
||||
// А узнанный по заголовку берёт токен и проходит: путь «узнавание → токен
|
||||
// файла → ссылка» обязан работать целиком, иначе файл записи недостижим для
|
||||
// браузера вовсе.
|
||||
withToken := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(withToken,
|
||||
httptest.NewRequest(http.MethodGet, link+"?token="+fileToken(t, env, env.login), nil))
|
||||
require.Equal(t, http.StatusOK, withToken.Code)
|
||||
assert.Equal(t, "audio content", withToken.Body.String())
|
||||
|
||||
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
|
||||
fileRepo := pbrepo.NewFileRepository(env.app)
|
||||
reader, err := fileRepo.Open(files[0].Id)
|
||||
@@ -511,29 +498,144 @@ func TestRecordFileNeedsSession(t *testing.T) {
|
||||
assert.Equal(t, "audio content", string(content))
|
||||
}
|
||||
|
||||
// TestSessionLifetimeIsAssigned: срок жизни сессии назначен нами, а не достался
|
||||
// умолчанием библиотеки в пять суток.
|
||||
//
|
||||
// Назначается он приведением настроек при подъёме, а не шагом схемы: применённый
|
||||
// шаг не переписывается, и число, положенное туда, разошлось бы со сроком жизни
|
||||
// куки при первой же правке.
|
||||
func TestSessionLifetimeIsAssigned(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
// countAccounts — сколько учётных записей лежит в хранилище. Проверки судят
|
||||
// заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по
|
||||
// ответу обработчика они неразличимы.
|
||||
func countAccounts(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
records, err := env.app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration,
|
||||
"шаг схемы назначил срок сам — тогда правка числа до хранилища не доедет")
|
||||
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "local-test-secret",
|
||||
}))
|
||||
|
||||
users, err = app.FindCollectionByNameOrId("users")
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты
|
||||
// запрос не заводит учётной записи.
|
||||
//
|
||||
// Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше
|
||||
// ограничителя, он работал на запросах, которые тот уже отверг: сто двадцать
|
||||
// запросов выбирали бюджет, следующие пятьдесят получали отказ — и заводили
|
||||
// пятьдесят учётных записей. Убрать их потом нечем: учётная запись с записями
|
||||
// не удаляется, а мусорная растёт в той же единственной базе.
|
||||
func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
|
||||
// потолок раньше, чем начнутся новые имена.
|
||||
for range appRateMaxRequests + 5 {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
||||
}
|
||||
|
||||
rejected := 0
|
||||
for i := range 20 {
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest(http.MethodGet, "/app/me", nil),
|
||||
fmt.Sprintf("newcomer-%d", i)))
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
rejected++
|
||||
}
|
||||
}
|
||||
|
||||
require.Positive(t, rejected, "ограничитель не сработал — проверке не на чем сработать")
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"отвергнутый ограничителем запрос завёл учётную запись: узнавание стоит раньше ограничителя")
|
||||
}
|
||||
|
||||
// TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
|
||||
//
|
||||
// Без строки журнала «никто не заходил» неотличимо от «завелось двадцать», а
|
||||
// прокси, пропустивший чужой заголовок, не оставляет следа вовсе. Значение
|
||||
// заголовка при этом в строку не идёт: им довольно назваться, чтобы стать этим
|
||||
// человеком.
|
||||
func TestAccountCreationIsLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const login = "brand-new-person"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "Account created from login header", "заведение не оставило строки")
|
||||
assert.NotContains(t, journal, login, "значение заголовка уехало в журнал")
|
||||
|
||||
// Второе обращение новой строки не прибавляет: заводится запись однажды.
|
||||
before := strings.Count(journal, "Account created from login header")
|
||||
|
||||
again := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(again, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
require.Equal(t, http.StatusOK, again.Code)
|
||||
|
||||
assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
|
||||
"повторное обращение отчиталось заведением")
|
||||
}
|
||||
|
||||
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
|
||||
//
|
||||
// Два значения заголовка означают прокси, который его добавляет вместо замены,
|
||||
// — модель угроз называет это главным барьером. Отладочным уровнем такая
|
||||
// поломка в бою не видна вовсе: боевой уровень журнала информационный.
|
||||
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Add(LoginHeader, "intruder")
|
||||
req.Header.Add(LoginHeader, env.login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "level=WARN", "поломка контура записана уровнем, невидимым в бою")
|
||||
assert.Contains(t, journal, "more than one login header")
|
||||
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути
|
||||
// узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным.
|
||||
//
|
||||
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя
|
||||
// то, что сломано не у него.
|
||||
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
// Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так
|
||||
// же, как она перестанет работать при отказе хранилища.
|
||||
users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration)
|
||||
users.RemoveIndex("idx_users_provider_login")
|
||||
users.Fields.RemoveByName(migrations.ProviderLoginField)
|
||||
require.NoError(t, env.app.Save(users))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), "somebody"))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
|
||||
"отказ хранилища выдан за «вас не узнали»")
|
||||
assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
|
||||
}
|
||||
|
||||
// TestFormerAuthRootServesMarkup — прежние адреса входа отдают разметку.
|
||||
//
|
||||
// Корень `/auth` снят из перечня адресного пространства, и путь под ним стал
|
||||
// обычным путём вне корней. Проверка сторожит именно это: вернувшийся корень
|
||||
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы
|
||||
// поведение.
|
||||
func TestFormerAuthRootServesMarkup(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
res := env.get("/auth/login")
|
||||
|
||||
assert.Equal(t, http.StatusOK, res.Code)
|
||||
assert.Contains(t, res.Body.String(), "приложение")
|
||||
}
|
||||
|
||||
@@ -96,15 +96,19 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}
|
||||
|
||||
case errors.Is(err, contract.ErrUnauthorized):
|
||||
// Не «требуется вход»: своего входа у сервиса нет, и уводить человека
|
||||
// некуда. Сообщение называет то, что произошло на самом деле, — сервис
|
||||
// не узнал пришедшего, — и приложение показывает его как есть, своего
|
||||
// словаря текстов под коды ответа не заводя.
|
||||
return http.StatusUnauthorized, ErrorBody{
|
||||
Code: CodeUnauthorized,
|
||||
Message: "Требуется вход",
|
||||
Message: "Сервис вас не узнал",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrOwnerRequired):
|
||||
return http.StatusForbidden, ErrorBody{
|
||||
Code: CodeForbidden,
|
||||
Message: "У вашей сессии нет учётной записи пользователя",
|
||||
Message: "У предъявителя нет учётной записи пользователя",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,9 +218,13 @@ func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
|
||||
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberRequireUser",
|
||||
// Сразу после слоя, который читает предъявленный токен: раньше него
|
||||
// `e.Auth` ещё пуст, и всякий запрос получал бы отказ.
|
||||
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
|
||||
// Сразу после слоя узнавания: раньше него `e.Auth` ещё пуст, и всякий
|
||||
// запрос получал бы отказ. Слой узнавания, в свою очередь, стоит за
|
||||
// ограничителем частоты — см. `TrustedHeaderIdentity`.
|
||||
//
|
||||
// Предел тела библиотеки идёт следом (−990), и это обязательно: отказ
|
||||
// неузнанному наступает **до** чтения тела.
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 2,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return fail(e, contract.ErrUnauthorized)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
)
|
||||
|
||||
// Заголовки, которыми обратный прокси называет пришедшего.
|
||||
//
|
||||
// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же
|
||||
// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама
|
||||
// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена
|
||||
// соседним сервисам, и настройкой они не делаются: второе место, где их можно
|
||||
// написать неверно, выгоды не даёт.
|
||||
const (
|
||||
LoginHeader = "Remote-User"
|
||||
NameHeader = "Remote-Name"
|
||||
EmailHeader = "Remote-Email"
|
||||
)
|
||||
|
||||
// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла.
|
||||
//
|
||||
// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь
|
||||
// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе —
|
||||
// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге.
|
||||
const FileTokenPath = StorageRoot + "/files/token"
|
||||
|
||||
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
|
||||
//
|
||||
// # Область
|
||||
//
|
||||
// Слой вешается корневым — иначе к адресу выдачи файлового токена его не
|
||||
// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но
|
||||
// узнаёт он **только на объявленной области**: корень приложения плюс этот
|
||||
// адрес. Область выводится из перечня адресного пространства, а не пишется
|
||||
// вторым списком.
|
||||
//
|
||||
// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной
|
||||
// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища
|
||||
// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение
|
||||
// настоящего владельца этого имени попало бы в чужую запись вместе со всем
|
||||
// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции;
|
||||
// два барьера здесь именно потому, что прежний был один и держался на
|
||||
// случайности — на том, что браузер сам не шлёт заголовка авторизации.
|
||||
//
|
||||
// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и
|
||||
// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к
|
||||
// базе, а первый такой запрос с новым именем — записи в неё.
|
||||
//
|
||||
// # Кто побеждает
|
||||
//
|
||||
// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения
|
||||
// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его
|
||||
// учётной записью пользователя отобрала бы у него панель посреди работы.
|
||||
// Протухший и негодный токен предъявленными не считаются: библиотека их не
|
||||
// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком.
|
||||
//
|
||||
// # Чего слой не делает
|
||||
//
|
||||
// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты
|
||||
// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто
|
||||
// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием
|
||||
// учётной записи на адресах приложения.
|
||||
//
|
||||
// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не
|
||||
// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где
|
||||
// легла база.
|
||||
func TrustedHeaderIdentity(
|
||||
app core.App,
|
||||
mounts []Mount,
|
||||
trusted []netip.Prefix,
|
||||
logger *slog.Logger,
|
||||
) *hook.Handler[*core.RequestEvent] {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberTrustedHeaderIdentity",
|
||||
// **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а
|
||||
// на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно
|
||||
// работало на запросах, которые тот уже отверг. Замер: сто двадцать
|
||||
// запросов выбирают бюджет, следующие пятьдесят с новыми именами
|
||||
// получают отказ — и заводят пятьдесят учётных записей, которые потом не
|
||||
// убираются ничем.
|
||||
//
|
||||
// Порядок целиком: чтение токена (−1020) → ограничитель (−1000) →
|
||||
// узнавание (−999) → требование учётной записи (−998) → предел тела
|
||||
// (−990). Требование стоит перед пределом тела намеренно: отказ
|
||||
// неузнанному обязан наступать до чтения тела.
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 1,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// Более одного значения — не выбор, а отказ. Прокси, настроенный
|
||||
// добавлять заголовок вместо замены, оставляет рядом со своим
|
||||
// значением присланное анонимом, и умолчание «берём первое» отдало
|
||||
// бы вход анониму.
|
||||
values := e.Request.Header.Values(LoginHeader)
|
||||
if len(values) != 1 {
|
||||
if len(values) > 1 {
|
||||
// Уровень предупреждающий: два значения означают прокси,
|
||||
// который заголовок **добавляет** вместо замены, — то есть
|
||||
// ровно ту поломку контура, которую модель угроз называет
|
||||
// главной. Отладочным уровнем она в бою не видна вовсе:
|
||||
// боевой уровень журнала информационный.
|
||||
logger.Warn("Request carries more than one login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
} else {
|
||||
// Заголовка нет вовсе. В этом контуре это значит, что прокси
|
||||
// никого не назвал; строка отладочная, потому что случай
|
||||
// штатный — так выглядит и человек, которому провайдер
|
||||
// отказал.
|
||||
logger.Debug("Request carries no login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
peer, ok := peerAddress(e.Request.RemoteAddr)
|
||||
if !ok || !isTrusted(trusted, peer) {
|
||||
// Уровень предупреждающий, а не отладочный, и это решение о
|
||||
// цене. Заголовок с недоверенного адреса в этом контуре — не
|
||||
// рутина: контейнер портов наружу не публикует, снаружи всё
|
||||
// приходит прокси, то есть с доверенного адреса. Значит либо
|
||||
// перечень задан неверно, либо кто-то оказался внутри сети — и
|
||||
// то и другое владелец обязан увидеть.
|
||||
//
|
||||
// Значение заголовка при этом в журнал не идёт: оно целиком
|
||||
// задаётся тем, кто шлёт запрос. Адрес пира идёт — по нему
|
||||
// видно, чья это поломка: своя (перечень) или контура (прокси
|
||||
// заголовка не ставит).
|
||||
logger.Warn("Login header came from an untrusted peer",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{
|
||||
Login: values[0],
|
||||
Name: e.Request.Header.Get(NameHeader),
|
||||
Email: e.Request.Header.Get(EmailHeader),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pbrepo.ErrLoginNotAcceptable) {
|
||||
// Негодный логин — это негодный ввод, а не поломка сервиса:
|
||||
// пустой заголовок прокси шлёт штатно там, где никого не
|
||||
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
|
||||
// неузнанным.
|
||||
logger.Debug("Login header value is not acceptable",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
logger.Error("Failed to resolve account by login header",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return fail(e, err)
|
||||
}
|
||||
|
||||
if created {
|
||||
// Заведение учётной записи — событие, и владелец обязан его
|
||||
// видеть: иначе «никто не заходил» неотличимо от «завелось
|
||||
// двадцать», а прокси, пропустивший чужой заголовок, не
|
||||
// оставляет следа вовсе. Убрать заведённую запись потом нечем —
|
||||
// учётная запись с записями не удаляется.
|
||||
//
|
||||
// Значение заголовка в строку не идёт: им довольно назваться,
|
||||
// чтобы стать этим человеком. Идут адрес пира и идентификатор
|
||||
// записи — оба выданы не спрашивающим.
|
||||
logger.Info("Account created from login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"account_id", record.Id,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
|
||||
e.Auth = record
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути.
|
||||
//
|
||||
// Область — корень приложения из перечня адресного пространства плюс адрес
|
||||
// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень
|
||||
// объявлен единой точкой адресного пространства, и записанный здесь второй раз
|
||||
// он разошёлся бы с ней молча.
|
||||
func underIdentifiedArea(mounts []Mount, requestPath string) bool {
|
||||
if requestPath == FileTokenPath {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, mount := range mounts {
|
||||
if mount.Path == AppRoot {
|
||||
return mount.Covers(requestPath)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// peerAddress достаёт адрес того, кто открыл соединение.
|
||||
//
|
||||
// Берётся именно он, а не пересылаемый заголовок: значением пересылаемого
|
||||
// распоряжается тот, кто шлёт запрос, и барьер, подделываемый той же строкой,
|
||||
// которой он обходится, не барьер вовсе.
|
||||
func peerAddress(remoteAddr string) (netip.Addr, bool) {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
// Адрес без порта — законная форма у некоторых слушателей.
|
||||
host = remoteAddr
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// Адрес IPv4, приехавший в оболочке IPv6, сверяется с перечнем как IPv4:
|
||||
// иначе `127.0.0.1` в перечне не совпал бы с `::ffff:127.0.0.1` у пира.
|
||||
return addr.Unmap(), true
|
||||
}
|
||||
|
||||
func isTrusted(trusted []netip.Prefix, peer netip.Addr) bool {
|
||||
for _, network := range trusted {
|
||||
if network.Contains(peer) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func TestList_ShowsOnlyOwnRecords(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: stranger})
|
||||
asUser(req, stranger)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// Проверки этого файла проходят вход целиком — от увода к провайдеру до куки
|
||||
// сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки
|
||||
// заводят учётную запись прямым сохранением и останавливаются раньше обмена.
|
||||
|
||||
// fakeProvider — подставной провайдер OIDC. Отдаёт токен и сведения о человеке,
|
||||
// считая обращения: по счётчику видно, дошло ли до сети вообще.
|
||||
type fakeProvider struct {
|
||||
server *httptest.Server
|
||||
tokenHits int
|
||||
failToken bool
|
||||
subject string
|
||||
emailValue string
|
||||
}
|
||||
|
||||
func newFakeProvider(t *testing.T) *fakeProvider {
|
||||
t.Helper()
|
||||
|
||||
provider := &fakeProvider{subject: "person-sub-1", emailValue: "person@example.com"}
|
||||
|
||||
write := func(w http.ResponseWriter, body string) {
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Errorf("подставной провайдер не ответил: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
provider.tokenHits++
|
||||
if provider.failToken {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
write(w, `{"error":"invalid_grant"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
write(w, `{"access_token":"provider-access-token","token_type":"bearer","expires_in":3600}`)
|
||||
})
|
||||
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
write(w, `{"sub":"`+provider.subject+`","email":"`+provider.emailValue+`","name":"Person","email_verified":true}`)
|
||||
})
|
||||
|
||||
provider.server = httptest.NewServer(mux)
|
||||
t.Cleanup(provider.server.Close)
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
// loginEnv — окружение проверки входа: хранилище с настроенным подставным
|
||||
// провайдером и собранный роутер со всеми слоями.
|
||||
type loginEnv struct {
|
||||
app core.App
|
||||
mux http.Handler
|
||||
handler *AuthHandler
|
||||
provider *fakeProvider
|
||||
}
|
||||
|
||||
func setupLoginEnv(t *testing.T) *loginEnv {
|
||||
t.Helper()
|
||||
|
||||
app := newTestStorage(t)
|
||||
provider := newFakeProvider(t)
|
||||
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||
AuthURL: provider.server.URL + "/authorize",
|
||||
TokenURL: provider.server.URL + "/token",
|
||||
UserInfoURL: provider.server.URL + "/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "local-test-secret",
|
||||
}))
|
||||
|
||||
handler := NewAuthHandler(app, AuthHandlerConfig{
|
||||
AuthURL: provider.server.URL + "/authorize",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &loginEnv{app: app, mux: mux, handler: handler, provider: provider}
|
||||
}
|
||||
|
||||
// startLogin проходит первый шаг входа и отдаёт носитель состояния вместе с
|
||||
// выданным состоянием — тем, что сервис ждёт обратно.
|
||||
func (e *loginEnv) startLogin(t *testing.T) (cookie *http.Cookie, state string) {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
require.Equal(t, http.StatusFound, w.Code)
|
||||
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == stateCookieName {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, cookie, "носитель состояния не поставлен")
|
||||
|
||||
location, err := url.Parse(w.Result().Header.Get("Location"))
|
||||
require.NoError(t, err)
|
||||
state = location.Query().Get("state")
|
||||
require.NotEmpty(t, state)
|
||||
|
||||
return cookie, state
|
||||
}
|
||||
|
||||
// TestLoginCreatesAccountAndSession — вход целиком: человека заводят по слову
|
||||
// провайдера, и он получает сессию.
|
||||
//
|
||||
// Без этой проверки закрытое создание записи в коллекции пользователей выглядит
|
||||
// работающим: прочие проверки заводят запись мимо входа.
|
||||
func TestLoginCreatesAccountAndSession(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
before, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, before, "учётных записей быть не должно: шаг схемы их не заводит")
|
||||
|
||||
cookie, state := env.startLogin(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusFound, w.Code, "вход не прошёл: тело %s", w.Body.String())
|
||||
assert.Equal(t, 1, env.provider.tokenHits, "обмен до провайдера не дошёл")
|
||||
|
||||
after, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, after, 1, "учётная запись не заведена — войти не может никто")
|
||||
|
||||
var session *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookieName {
|
||||
session = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, session, "кука сессии не поставлена")
|
||||
|
||||
// Признаки куки нормативны: их потеря делает сессию доступной скриптам либо
|
||||
// уносит её по незашифрованному соединению.
|
||||
assert.True(t, session.HttpOnly)
|
||||
assert.True(t, session.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, session.SameSite)
|
||||
assert.Equal(t, pbrepo.SessionDuration, session.MaxAge)
|
||||
assert.NotEmpty(t, session.Value)
|
||||
|
||||
// Носитель состояния убран — и убран так, что это видно готовому ответу, а
|
||||
// не только живой карте заголовков.
|
||||
var cleared bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == stateCookieName && c.MaxAge < 0 {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
assert.True(t, cleared, "носитель состояния пережил возврат")
|
||||
|
||||
// Выданная сессия открывает доступ к закрытым адресам.
|
||||
check := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
check.AddCookie(session)
|
||||
checkResponse := httptest.NewRecorder()
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
NewAppHandler(
|
||||
pbrepo.NewAudioRecordRepository(env.app),
|
||||
pbrepo.NewTextRepository(env.app),
|
||||
pbrepo.NewStructureRepository(env.app),
|
||||
nil, nil,
|
||||
).Register(r)
|
||||
checkMux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
checkMux.ServeHTTP(checkResponse, check)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, checkResponse.Code,
|
||||
"сессия не открыла доступ: получен %d", checkResponse.Code)
|
||||
}
|
||||
|
||||
// TestSelfServiceRegistrationStaysClosed: правило создания пускает обмен и не
|
||||
// пускает постороннего.
|
||||
func TestSelfServiceRegistrationStaysClosed(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
|
||||
"посторонний завёл себе учётную запись: %s", w.Body.String())
|
||||
|
||||
accounts, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, accounts)
|
||||
}
|
||||
|
||||
// TestCallbackDoesNotLeakHooks: обмен не копит обработчики приложения.
|
||||
//
|
||||
// Сборка роутера хранилища вешает обработчики на само приложение и без
|
||||
// идентификатора, поэтому повторная не заменяет прежние. Собранный на каждый
|
||||
// вход, роутер копил бы их без предела — и копил бы по запросу анонима, потому
|
||||
// что обмен исполняется раньше обращения к провайдеру.
|
||||
func TestCallbackDoesNotLeakHooks(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
count := func() int {
|
||||
hook := reflect.ValueOf(env.app.OnModelAfterCreateSuccess()).Elem().FieldByName("handlers")
|
||||
return hook.Len()
|
||||
}
|
||||
|
||||
// Первый вход собирает роутер — с него и считаем.
|
||||
cookie, state := env.startLogin(t)
|
||||
first := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
|
||||
first.AddCookie(cookie)
|
||||
env.mux.ServeHTTP(httptest.NewRecorder(), first)
|
||||
|
||||
before := count()
|
||||
|
||||
for range 20 {
|
||||
cookie, state := env.startLogin(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
env.mux.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
assert.Equal(t, before, count(),
|
||||
"обработчики копятся: 20 входов добавили %d", count()-before)
|
||||
}
|
||||
|
||||
// TestSessionRefreshIsClosed: сессия не продлевает саму себя.
|
||||
//
|
||||
// При живом продлении срок её жизни ничего не значит, а вместе с ним перестаёт
|
||||
// работать единственный канал, которым отзыв доступа у провайдера доходит до
|
||||
// сервиса.
|
||||
func TestSessionRefreshIsClosed(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
cookie, state := env.startLogin(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusFound, w.Code)
|
||||
|
||||
var session *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookieName {
|
||||
session = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, session)
|
||||
|
||||
refresh := httptest.NewRequest(http.MethodPost, RefreshPath, nil)
|
||||
refresh.Header.Set("Authorization", session.Value)
|
||||
refreshResponse := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(refreshResponse, refresh)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, refreshResponse.Code,
|
||||
"сессия продлилась: %s", refreshResponse.Body.String())
|
||||
|
||||
// И нового значения в ответе нет — продлевать нечем.
|
||||
assert.NotContains(t, refreshResponse.Body.String(), `"token"`)
|
||||
}
|
||||
|
||||
// TestCallbackRejectsProviderFailure: отказ обмена не открывает сессию.
|
||||
func TestCallbackRejectsProviderFailure(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
env.provider.failToken = true
|
||||
|
||||
cookie, state := env.startLogin(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
for _, c := range w.Result().Cookies() {
|
||||
assert.NotEqual(t, SessionCookieName, c.Name, "сессия открыта на отказе обмена")
|
||||
}
|
||||
|
||||
accounts, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, accounts)
|
||||
}
|
||||
|
||||
// TestRecordFileNeedsSessionAndToken: файл записи отдаётся вошедшему и не
|
||||
// отдаётся анониму.
|
||||
func TestRecordFileNeedsSessionAndToken(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
|
||||
names := files[0].GetStringSlice("file")
|
||||
require.Len(t, names, 1)
|
||||
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
|
||||
|
||||
// Аноним не проходит.
|
||||
anonymous := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
|
||||
assert.GreaterOrEqual(t, anonymous.Code, http.StatusBadRequest)
|
||||
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
||||
|
||||
// Вошедший берёт короткоживущий токен файла и проходит по ссылке с ним:
|
||||
// защищённый файл судится этим токеном, а не сессионной кукой.
|
||||
tokenRequest := httptest.NewRequest(http.MethodPost, "/api/files/token", nil)
|
||||
tokenRequest.Header.Set("Authorization", env.session)
|
||||
tokenResponse := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(tokenResponse, tokenRequest)
|
||||
require.Equal(t, http.StatusOK, tokenResponse.Code, "токен файла не выдан: %s", tokenResponse.Body.String())
|
||||
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(tokenResponse.Body.Bytes(), &payload))
|
||||
require.NotEmpty(t, payload.Token)
|
||||
|
||||
withToken := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+payload.Token, nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, withToken.Code,
|
||||
"вошедший не получил файл: %d", withToken.Code)
|
||||
assert.Contains(t, withToken.Body.String(), "audio content")
|
||||
}
|
||||
@@ -17,40 +17,30 @@ import (
|
||||
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
|
||||
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
|
||||
|
||||
// newSecondAccount заводит вторую учётную запись с собственной сессией.
|
||||
// Постоянный адрес почты первой занят, и повторное сохранение отвергается —
|
||||
// адрес здесь свой.
|
||||
// newSecondAccount заводит вторую учётную запись со своим логином. Постоянный
|
||||
// адрес почты первой занят, и повторное сохранение отвергается — адрес здесь
|
||||
// свой.
|
||||
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
t.Helper()
|
||||
|
||||
const login = "stranger"
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
record.Set("email", "stranger@example.com")
|
||||
record.Set("verified", true)
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
token, err := record.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
return record, token
|
||||
return record, login
|
||||
}
|
||||
|
||||
// withSessionHeader предъявляет сессию заголовком. Собственная поверхность
|
||||
// хранилища читается только так: слой, перекладывающий куку в заголовок, на неё
|
||||
// намеренно не наведён — часть её защищена ровно тем, что браузер заголовка сам
|
||||
// не шлёт.
|
||||
func withSessionHeader(req *http.Request, session string) *http.Request {
|
||||
req.Header.Set("Authorization", session)
|
||||
return req
|
||||
}
|
||||
|
||||
// serveAs шлёт запрос от имени названной сессии, а не сессии окружения.
|
||||
func serveAs(env *testEnv, session string, w http.ResponseWriter, req *http.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: session})
|
||||
env.mux.ServeHTTP(w, req)
|
||||
// serveAs шлёт запрос от имени названного логина, а не логина окружения.
|
||||
func serveAs(env *testEnv, login string, w http.ResponseWriter, req *http.Request) {
|
||||
env.mux.ServeHTTP(w, asUser(req, login))
|
||||
}
|
||||
|
||||
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
|
||||
@@ -139,8 +129,13 @@ func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) {
|
||||
token, err := admin.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Владелец панели предъявляет **свой токен**, а не заголовок: заголовок ему
|
||||
// никто не ставит, и узнавание по нему его учётной записи не касается.
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
serveAs(env, token, w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей")
|
||||
assert.Equal(t, 0, countJobs(t, env), "задачи не заведено")
|
||||
@@ -164,13 +159,15 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
||||
|
||||
link := "/api/files/files/" + record.Id + "/" + record.GetString("file")
|
||||
|
||||
// Переход по ссылке идёт **без** заголовка: файл судится своим коротким
|
||||
// токеном, а не узнаванием. Так же по ней пойдёт и браузер.
|
||||
mine := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(mine, withSessionHeader(
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.session), http.NoBody), env.session))
|
||||
env.mux.ServeHTTP(mine,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.login), http.NoBody))
|
||||
|
||||
foreign := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(foreign, withSessionHeader(
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody), stranger))
|
||||
env.mux.ServeHTTP(foreign,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
|
||||
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
|
||||
@@ -179,15 +176,19 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
||||
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
|
||||
}
|
||||
|
||||
// fileToken берёт у хранилища токен файла для названной сессии. Токен выдаётся
|
||||
// fileToken берёт у хранилища токен файла для названного логина. Токен выдаётся
|
||||
// на предъявителя: о файле хранилище при выдаче не спрашивает.
|
||||
func fileToken(t *testing.T, env *testEnv, session string) string {
|
||||
//
|
||||
// Адрес выдачи лежит в пространстве хранилища, а не приложения, и узнавание на
|
||||
// нём работает поимённо — иначе весь путь «узнавание → токен файла → ссылка»
|
||||
// обрывался бы на первом шаге, и файл записи стал бы недостижим для браузера.
|
||||
func fileToken(t *testing.T, env *testEnv, login string) string {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, withSessionHeader(
|
||||
httptest.NewRequest("POST", "/api/files/token", http.NoBody), session))
|
||||
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому вошедшему")
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest("POST", "/api/files/token", http.NoBody), login))
|
||||
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому узнанному")
|
||||
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
|
||||
@@ -56,3 +56,37 @@ func ApplyAppRateLimit(app core.App) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать
|
||||
// адрес спрашивающего.
|
||||
//
|
||||
// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с
|
||||
// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет
|
||||
// в этом случае общий на весь сервис: восемь одновременно открытых карточек
|
||||
// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного
|
||||
// запроса. Норма при этом требует обратного — бюджет считается по адресу
|
||||
// спрашивающего.
|
||||
//
|
||||
// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес
|
||||
// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно
|
||||
// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру,
|
||||
// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**,
|
||||
// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ
|
||||
// счётчика сам и обходит ограничитель, меняя значение.
|
||||
//
|
||||
// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл,
|
||||
// по-прежнему решает адрес самого соединения.
|
||||
func ApplyTrustedProxyHeaders(app core.App) error {
|
||||
settings := app.Settings()
|
||||
|
||||
settings.TrustedProxy.Headers = []string{"X-Forwarded-For"}
|
||||
// Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда
|
||||
// падает обратно на адрес пира — то есть на общий, как было. Лучше общий,
|
||||
// чем один пустой ключ на всех.
|
||||
settings.TrustedProxy.UseLeftmostIP = true
|
||||
|
||||
if err := app.Save(settings); err != nil {
|
||||
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
)
|
||||
|
||||
// RefreshPath — адрес хранилища, которым сессия продлевает саму себя.
|
||||
const RefreshPath = "/api/collections/users/auth-refresh"
|
||||
|
||||
// BlockSessionRefresh закрывает продление сессии.
|
||||
//
|
||||
// Хранилище выдаёт сессию продлеваемой: предъявитель значения меняет его на
|
||||
// новое, с новым сроком, и делает это сколько угодно раз, никуда не входя. При
|
||||
// живом продлении срок жизни сессии перестаёт что-либо значить, а вместе с ним
|
||||
// перестаёт работать единственный канал, которым отзыв доступа у провайдера
|
||||
// доходит до сервиса, — сервис после входа к провайдеру не обращается.
|
||||
//
|
||||
// Решение владельца от 2026-08-12: продление выключено, цена — вход раз в
|
||||
// семь суток.
|
||||
func BlockSessionRefresh() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberBlockSessionRefresh",
|
||||
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 2,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Request.URL.Path == RefreshPath {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "Продление сессии выключено",
|
||||
})
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SessionFromCookie перекладывает значение куки сессии в заголовок, которым
|
||||
// хранилище читает предъявленную сессию.
|
||||
//
|
||||
// Куки хранилище не читает вовсе — только заголовок `Authorization`. Браузер же
|
||||
// сам заголовка не шлёт, а своей страницы со скриптом у сервиса нет, поэтому
|
||||
// сессия предъявляется кукой, а способ проверки остаётся один.
|
||||
//
|
||||
// Предъявленный заголовок побеждает: иначе браузер с сессионной кукой получал
|
||||
// бы на собственных адресах хранилища не то, что предъявил.
|
||||
//
|
||||
// Слой стоит раньше проверки токена: тот идёт с приоритетом
|
||||
// DefaultLoadAuthTokenMiddlewarePriority и к этому моменту заголовок должен
|
||||
// быть на месте.
|
||||
func SessionFromCookie() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberSessionFromCookie",
|
||||
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 1,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Request.Header.Get("Authorization") != "" {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
cookie, err := e.Request.Cookie(SessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
e.Request.Header.Set("Authorization", cookie.Value)
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -261,12 +261,13 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil))
|
||||
handler.Register(r)
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
asUser(req, env.login)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -72,50 +73,70 @@ type testEnv struct {
|
||||
handler *AppHandler
|
||||
app core.App
|
||||
journal *journalBuffer
|
||||
// session — значение сессии вошедшего. Приём и опрос закрыты за
|
||||
// аутентификацией, и проверка, судящая их по существу, обязана предъявить
|
||||
// сессию ровно так же, как это делает браузер.
|
||||
session string
|
||||
// account — учётная запись, которой выдана сессия. Нужна проверкам выхода.
|
||||
// login — логин у провайдера, которым доверенный источник называет
|
||||
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
|
||||
// по существу, обязана назваться ровно так же, как это делает прокси.
|
||||
login string
|
||||
// account — учётная запись, которой принадлежит этот логин.
|
||||
account *core.Record
|
||||
}
|
||||
|
||||
// serve шлёт запрос от имени вошедшего: сессия предъявляется кукой — тем же
|
||||
// способом, каким её предъявляет браузер. Заголовок проверки не ставят: куку в
|
||||
// него перекладывает слой предъявления, и подмена его здесь означала бы проверку
|
||||
// не той цепочки.
|
||||
// Доверенный источник проверок.
|
||||
//
|
||||
// Проверки, судящие отказ без сессии, зовут `mux` напрямую.
|
||||
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
|
||||
e.mux.ServeHTTP(w, req)
|
||||
// Подсеть и адрес пира разведены намеренно: проверка недоверенного источника
|
||||
// берёт адрес **вне** подсети, и обе стороны условия видны рядом.
|
||||
const (
|
||||
trustedPeer = "10.1.2.3:34567"
|
||||
untrustedPeer = "203.0.113.9:34567"
|
||||
)
|
||||
|
||||
func testTrustedNetworks(t *testing.T) []netip.Prefix {
|
||||
t.Helper()
|
||||
|
||||
prefix, err := netip.ParsePrefix("10.0.0.0/8")
|
||||
require.NoError(t, err)
|
||||
|
||||
return []netip.Prefix{prefix}
|
||||
}
|
||||
|
||||
// newTestAccount заводит учётную запись и выдаёт ей сессию.
|
||||
// asUser делает запрос запросом названного человека: ставит заголовок и адрес
|
||||
// пира из доверенной подсети — ровно то, что делает обратный прокси.
|
||||
func asUser(req *http.Request, login string) *http.Request {
|
||||
req.Header.Set(LoginHeader, login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
return req
|
||||
}
|
||||
|
||||
// serve шлёт запрос от имени узнанного. Проверки, судящие отказ неузнанному,
|
||||
// зовут `mux` напрямую: запрос без заголовка и есть запрос неузнанного.
|
||||
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
|
||||
e.mux.ServeHTTP(w, asUser(req, e.login))
|
||||
}
|
||||
|
||||
// newTestAccount заводит учётную запись с известным логином.
|
||||
//
|
||||
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
|
||||
// запросом больше нельзя — создание закрыто шагом схемы, и в этом весь смысл
|
||||
// изменения. Прямое сохранение идёт мимо правил доступа так же, как идёт вход,
|
||||
// когда учётную запись заводит само хранилище.
|
||||
// запросом нельзя — создание закрыто шагом схемы. Прямое сохранение идёт мимо
|
||||
// правил доступа так же, как идёт заведение записи первым обращением.
|
||||
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
t.Helper()
|
||||
|
||||
const login = "person"
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
record.Set("email", "person@example.com")
|
||||
record.Set("verified", true)
|
||||
// Случайный пароль ставит и само хранилище, когда заводит запись по входу у
|
||||
// провайдера: запись auth-коллекции без пароля не сохраняется, а войти по
|
||||
// Случайный пароль ставит и сам сервис, когда заводит запись первым
|
||||
// обращением: запись auth-коллекции без пароля не сохраняется, а войти по
|
||||
// нему всё равно нельзя — парольный вход выключен шагом схемы.
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
token, err := record.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
return record, token
|
||||
return record, login
|
||||
}
|
||||
|
||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||
@@ -191,23 +212,27 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
|
||||
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
|
||||
|
||||
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
|
||||
// обработчик, и проверка судит ту же цепочку, что и прод.
|
||||
// Роутер собирается тем же способом, что и боевой: слой узнавания вешается
|
||||
// корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку,
|
||||
// что и прод. Собери роутер иначе — и проверка судила бы не то.
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
mounts := ServiceMounts(handler, http.NotFoundHandler())
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
||||
handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
account, session := newTestAccount(t, app)
|
||||
account, login := newTestAccount(t, app)
|
||||
|
||||
return &testEnv{
|
||||
mux: mux,
|
||||
handler: handler,
|
||||
app: app,
|
||||
journal: journal,
|
||||
session: session,
|
||||
login: login,
|
||||
account: account,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,16 @@ import (
|
||||
//
|
||||
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
|
||||
// панели владельца. Поменять их нельзя, это литералы библиотеки.
|
||||
//
|
||||
// Корня `/auth` здесь больше нет: собственного входа у сервиса не осталось, и
|
||||
// адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают
|
||||
// тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
|
||||
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
|
||||
// стоит, ничем не отличается от любого другого свободного, а второй перечень
|
||||
// «когда-то занятых корней» разошёлся бы с первым молча.
|
||||
const (
|
||||
StorageRoot = "/api"
|
||||
PanelRoot = "/_"
|
||||
AuthRoot = "/auth"
|
||||
|
||||
HealthPath = "/health"
|
||||
MetricsPath = "/metrics"
|
||||
@@ -74,16 +80,11 @@ func (m Mount) Covers(requestPath string) bool {
|
||||
}
|
||||
|
||||
// ServiceMounts перечисляет адресное пространство сервиса целиком.
|
||||
func ServiceMounts(
|
||||
appHandler *AppHandler,
|
||||
authHandler *AuthHandler,
|
||||
metricsHandler http.Handler,
|
||||
) []Mount {
|
||||
func ServiceMounts(appHandler *AppHandler, metricsHandler http.Handler) []Mount {
|
||||
return []Mount{
|
||||
{Path: StorageRoot},
|
||||
{Path: PanelRoot},
|
||||
{Path: AppRoot, Bind: appHandler.Register},
|
||||
{Path: AuthRoot, Bind: authHandler.Register},
|
||||
{Path: HealthPath, Exact: true, Bind: bindHealth},
|
||||
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func builtDist() fs.FS {
|
||||
type webappEnv struct {
|
||||
mux http.Handler
|
||||
journal *journalBuffer
|
||||
session string
|
||||
login string
|
||||
}
|
||||
|
||||
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
||||
@@ -68,36 +68,32 @@ func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
||||
)
|
||||
|
||||
appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
|
||||
authHandler := NewAuthHandler(app, AuthHandlerConfig{
|
||||
AuthURL: "https://provider.example/authorize",
|
||||
RedirectURL: "https://service.example/auth/callback",
|
||||
ClientID: "client",
|
||||
}, logger)
|
||||
|
||||
mounts := ServiceMounts(appHandler, authHandler, http.NotFoundHandler())
|
||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
||||
RegisterServiceRoutes(r, mounts)
|
||||
NewWebappHandler(dist, built, mounts, logger).Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, session := newTestAccount(t, app)
|
||||
_, login := newTestAccount(t, app)
|
||||
|
||||
return &webappEnv{mux: mux, journal: journal, session: session}
|
||||
return &webappEnv{mux: mux, journal: journal, login: login}
|
||||
}
|
||||
|
||||
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||
return e.do(http.MethodGet, path, false)
|
||||
}
|
||||
|
||||
func (e *webappEnv) do(method, path string, withSession bool) *httptest.ResponseRecorder {
|
||||
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if withSession {
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
|
||||
if identified {
|
||||
asUser(req, e.login)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -124,7 +120,7 @@ func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
for _, path := range []string{"/api/nope", "/_/nope", "/auth/nope"} {
|
||||
for _, path := range []string{"/api/nope", "/_/nope"} {
|
||||
res := env.get(path)
|
||||
|
||||
assert.NotContains(t, res.Body.String(), "приложение", path)
|
||||
|
||||
Reference in New Issue
Block a user