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 } if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] { 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 }