Files
transcriber/internal/controller/http/auth.go
T
av 663021f712 приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
2026-08-15 18:51:05 +03:00

383 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
})
}