Files
pipu-music/services/api/internal/auth/middleware.go
T
mrleo1nid f1528de948
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
feat(admin): transfer user accounts and show local cover coverage
2026-10-23 02:17:06 +03:00

175 lines
6.0 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,
// Клиенты раздаются по ссылке /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/") ||
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)
}
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 }