180 lines
5.5 KiB
Go
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
|
|
}
|