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 } // Обложки открывает без заголовка Authorization — ссылку защищает подпись. // Аудио открывает