Files
pipu-music/services/api/internal/auth/middleware.go
T
mrleo1nid 37e33c656b
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 47s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m42s
ci / ts / ts (packages + web) (push) Failing after 5m24s
ci / api / api (push) Successful in 42s
fix(uploads): allow signed audio previews and compact destination details
2026-10-29 11:21:48 +03:00

196 lines
6.6 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 auth
import (
"context"
"io"
"log/slog"
"net"
"net/http"
"strings"
"github.com/google/uuid"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx"
)
type ctxKey int
const (
principalKey ctxKey = iota
requestKey
)
// requestInfo — то, что strict-обработчикам нужно от HTTP-запроса, но не входит в контракт:
// адрес клиента и cookie, которые нужно выставить в ответе.
type requestInfo struct {
ip string
cookies []*http.Cookie
}
// PrincipalFrom возвращает пользователя запроса (ok = false для публичных эндпоинтов).
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
// WithPrincipal кладёт пользователя в контекст (для тестов других модулей).
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, principalKey, p)
}
func clientIP(ctx context.Context) string {
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
return ri.ip
}
return ""
}
func setCookie(ctx context.Context, c *http.Cookie) {
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
ri.cookies = append(ri.cookies, c)
}
}
// Публичные эндпоинты /api/v1 — без access-токена.
var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true,
// Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения.
"GET /api/v1/app/android/latest": true,
"GET /api/v1/app/clients": true,
}
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
var (
credentialsChangeRoutes = map[string]bool{
"GET /api/v1/me": true,
"POST /api/v1/me/bootstrap-credentials": true,
"POST /api/v1/auth/logout": true,
}
passwordChangeRoutes = map[string]bool{
"GET /api/v1/me": true,
"POST /api/v1/me/password": true,
"POST /api/v1/auth/logout": true,
}
)
// Middleware аутентифицирует запросы к /api/v1 по Bearer-токену и ограничивает пользователей,
// обязанных сменить пароль или учётные данные (docs/auth-and-roles.md).
func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ri := &requestInfo{ip: hostOnly(r.RemoteAddr)}
ctx := context.WithValue(r.Context(), requestKey, ri)
w = &cookieWriter{ResponseWriter: w, ri: ri}
route := r.Method + " " + strings.TrimSuffix(r.URL.Path, "/")
if route == "POST /api/v1/auth/refresh" && r.ContentLength == 0 {
// Веб шлёт refresh без тела (токен в cookie), а strict-сервер oapi-codegen
// декодирует необязательное тело всё равно и отвечает 400 на пустое.
r.Body = io.NopCloser(strings.NewReader("{}"))
r.ContentLength = 2
}
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
isUploadStreamPath(r.URL.Path) ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
token, ok := bearer(r.Header.Get("Authorization"))
if !ok {
httpx.WriteError(log, w, r, apperr.Unauthorized(CodeUnauthorized, "bearer token required"))
return
}
p, err := svc.Authenticate(ctx, token)
if err != nil {
httpx.WriteError(log, w, r, err)
return
}
switch {
case p.MustChangeCredentials && !credentialsChangeRoutes[route]:
httpx.WriteError(log, w, r, apperr.Forbidden(CodeCredentialsChangeRequired, "change bootstrap credentials first"))
return
case p.MustChangePassword && !passwordChangeRoutes[route]:
httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first"))
return
}
if route == "POST /api/v1/admin/users/import" {
r.Body = http.MaxBytesReader(w, r.Body, MaxUserArchiveBytes)
}
if (r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch) && strings.HasPrefix(r.URL.Path, "/api/v1/me/social/") {
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
})
}
}
// Only the signed audio endpoint bypasses Bearer authentication. Its handler
// verifies the signature and expiry before accessing staging.
func isUploadStreamPath(urlPath string) bool {
id, ok := strings.CutPrefix(urlPath, "/api/v1/admin/uploads/files/")
if !ok {
return false
}
id, ok = strings.CutSuffix(id, "/stream")
if !ok {
return false
}
_, err := uuid.Parse(id)
return err == nil
}
func bearer(h string) (string, bool) {
const prefix = "bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return "", false
}
t := strings.TrimSpace(h[len(prefix):])
return t, t != ""
}
func hostOnly(addr string) string {
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
// cookieWriter добавляет накопленные обработчиком cookie перед записью заголовков ответа.
type cookieWriter struct {
http.ResponseWriter
ri *requestInfo
wrote bool
}
func (w *cookieWriter) flush() {
if w.wrote {
return
}
w.wrote = true
for _, c := range w.ri.cookies {
http.SetCookie(w.ResponseWriter, c)
}
}
func (w *cookieWriter) WriteHeader(code int) {
w.flush()
w.ResponseWriter.WriteHeader(code)
}
func (w *cookieWriter) Write(b []byte) (int, error) {
w.flush()
return w.ResponseWriter.Write(b)
}
// Unwrap нужен http.ResponseController (flush, дедлайны) для обёрнутого writer.
func (w *cookieWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }