- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
377 lines
16 KiB
Go
377 lines
16 KiB
Go
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())
|
||
|
||
r.GET("/auth/login", h.Login)
|
||
r.GET("/auth/callback", h.Callback)
|
||
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
|
||
// чужой ссылке.
|
||
//
|
||
// Слой предъявления нужен и здесь: без него выход не знает, чью сессию
|
||
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
|
||
// значение годным. Требования сессии при этом нет: выход без неё убирает
|
||
// куку и молчит.
|
||
r.POST("/auth/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,
|
||
})
|
||
}
|