Files
bot-pontos/backend/internal/httpapi/middleware.go
2026-10-23 22:31:02 -03:00

180 lines
5.5 KiB
Go

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
}