Projeto bot pontos
This commit is contained in:
179
backend/internal/httpapi/middleware.go
Normal file
179
backend/internal/httpapi/middleware.go
Normal file
@@ -0,0 +1,179 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user