Files
pipu-music/services/api/internal/auth/middleware.go
T
mrleo1nidandClaude Opus 5.5 8b76aa3964
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 34s
ci / api / api (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
feat: album covers with palettes and signed links (M2)
- covers are taken from the embedded picture of the first track or a
  cover/folder/front image in the album folder, converted by ffmpeg to
  WebP 64/300/1200 and named by content hash, so albums sharing a cover
  share the files; a full scan looks again for albums without one
- a 3-colour palette (dominant, second, most saturated accent) from a
  deterministic k-means feeds placeholders, page tints and the wave
- /covers/{id} is public but HMAC-signed and immutable; nginx serves
  the file via X-Accel-Redirect, X_ACCEL=false serves it from api
- the web client picks the size by display size and pixel density
- install.sh and update.sh generate STREAM_SIGNING_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:06:15 +03:00

165 lines
5.3 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"
"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,
}
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
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 — ссылку защищает подпись.
cover := r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/covers/")
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
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
})
}
}
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 }