package httpapi import ( "context" "errors" "net" "net/http" "net/netip" "strings" "time" "botpontos/internal/domain" "botpontos/internal/httpx" ) const ( sessionCookie = "bp_session" csrfCookie = "bp_csrf" csrfHeader = "X-CSRF-Token" ) type ctxKey int const sessionKey ctxKey = 1 func sessionFrom(r *http.Request) domain.WebSession { s, _ := r.Context().Value(sessionKey).(domain.WebSession) return s } // auth exige sessão válida e o papel mínimo. Em métodos que alteram dados, // exige também a origem do painel e o token CSRF (double submit, 5.4). func (a *API) auth(min domain.Role, next http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(sessionCookie) if err != nil { httpx.WriteProblem(w, http.StatusUnauthorized, "Faça login para continuar", "UNAUTHENTICATED") return } session, err := a.svc.Authenticate(r.Context(), cookie.Value) if err != nil { if errors.Is(err, domain.ErrUnauthenticated) { clearCookies(w, a.cfg.Web.CookieSecure) httpx.WriteProblem(w, http.StatusUnauthorized, "Sessão expirada; faça login novamente", "UNAUTHENTICATED") return } a.writeError(w, err) return } if !session.Account.Role.AtLeast(min) { httpx.WriteProblem(w, http.StatusForbidden, "Seu papel não permite esta ação", "FORBIDDEN") return } if unsafeMethod(r.Method) { if !a.sameOrigin(r) { httpx.WriteProblem(w, http.StatusForbidden, "Origem da requisição não permitida", "BAD_ORIGIN") return } if !a.svc.CheckCSRF(session, r.Header.Get(csrfHeader)) { httpx.WriteProblem(w, http.StatusForbidden, "Token CSRF inválido; recarregue a página", "BAD_CSRF") return } } next(w, r.WithContext(context.WithValue(r.Context(), sessionKey, session))) }) } // isRestricted indica a conta de leitura ligada a um usuário do Telegram: ela // só enxerga e altera os dados desse usuário. func isRestricted(a domain.WebAccount) bool { return a.Role == domain.RoleViewer && a.UserID != nil } // authUnrestricted é auth, mas recusa a conta restrita (telas globais). func (a *API) authUnrestricted(min domain.Role, next http.HandlerFunc) http.Handler { return a.auth(min, func(w http.ResponseWriter, r *http.Request) { if isRestricted(sessionFrom(r).Account) { httpx.WriteProblem(w, http.StatusForbidden, "Seu papel não permite esta ação", "FORBIDDEN") return } next(w, r) }) } // authUser protege rotas /users/{id}. Admin e owner passam sempre. A conta // restrita só passa no próprio usuário; com self, ela também pode alterá-lo // (mesmo sem o papel mínimo). Leitura sem vínculo mantém o papel de sempre. func (a *API) authUser(min domain.Role, self bool, next http.HandlerFunc) http.Handler { return a.auth(domain.RoleViewer, func(w http.ResponseWriter, r *http.Request) { account := sessionFrom(r).Account if !account.Role.AtLeast(domain.RoleAdmin) { id, ok := pathID(w, r) if !ok { return } own := account.UserID != nil && *account.UserID == id if (isRestricted(account) && !own) || (!account.Role.AtLeast(min) && !(self && own)) { httpx.WriteProblem(w, http.StatusForbidden, "Seu papel não permite esta ação", "FORBIDDEN") return } } next(w, r) }) } func unsafeMethod(method string) bool { return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions } // sameOrigin confere Origin (ou Referer) contra BP_PUBLIC_URL. Vazio desliga a checagem (desenvolvimento). func (a *API) sameOrigin(r *http.Request) bool { public := a.cfg.Web.PublicURL if public == "" { return true } if origin := r.Header.Get("Origin"); origin != "" { return origin == public } if referer := r.Header.Get("Referer"); referer != "" { return referer == public || strings.HasPrefix(referer, public+"/") } return false } func setSessionCookies(w http.ResponseWriter, token, csrf string, expires time.Time, secure bool) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: token, Path: "/", Expires: expires, HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, }) // Legível pelo JavaScript do painel, que o repete no header X-CSRF-Token. http.SetCookie(w, &http.Cookie{ Name: csrfCookie, Value: csrf, Path: "/", Expires: expires, HttpOnly: false, Secure: secure, SameSite: http.SameSiteStrictMode, }) } func clearCookies(w http.ResponseWriter, secure bool) { for _, name := range []string{sessionCookie, csrfCookie} { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: name == sessionCookie, Secure: secure, SameSite: http.SameSiteStrictMode, }) } } // clientIP percorre o X-Forwarded-For da direita para a esquerda e devolve o // primeiro endereço que não é um proxy confiável (NPM, Next, redes Docker). // O valor à esquerda pode ter sido enviado pelo próprio cliente, por isso não é usado diretamente. func (a *API) clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } remote, err := netip.ParseAddr(host) if err != nil || !a.trusted(remote) { return host } parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",") for i := len(parts) - 1; i >= 0; i-- { addr, err := netip.ParseAddr(strings.TrimSpace(parts[i])) if err != nil { break } if !a.trusted(addr) { return addr.String() } } return host } func (a *API) trusted(addr netip.Addr) bool { addr = addr.Unmap() for _, p := range a.cfg.Web.TrustedProxies { if p.Contains(addr) { return true } } return false }