fix(security): corrige achados da varredura — render, brute-force, upload, WYSIWYG, cookie, sessões
1. /api/render: cap de 512 KiB na entrada (amplificação ~2× medida); decodeJSON agora amarra http.MaxBytesReader ao ResponseWriter (413 real ao cliente em todos os endpoints JSON) 2. Login com rate-limit por IP em memória (5 falhas/15 min → 429, acerto reseta, RemoteAddr puro — X-F-F não é confiável) + log de falha com IP 3. Upload: MaxBytesReader no corpo inteiro (64 MiB + 64 KiB) antes do parse do multipart; quota global de 2 GiB (MaxFilesTotalBytes) em AddFile 4. sanitizeRich neutraliza href/src com javascript:/vbscript:/data: (case/whitespace-insensível); link legítimo preservado 5. Cookie Secure passa a ser configuração explícita (NOTES_SECURE_COOKIE) — X-Forwarded-Proto é spoofável; documentado em .env.example e compose 6. Cookie agora carrega id de sessão opaco (crypto/rand 32B, TTL 30d, revogação no logout e no restart); token mestre só vale via Bearer — furo pego pelo próprio teste novo (token no cookie era aceito) Testes: rate-limit, revogação, canais separados, render 413, upload cap sw v16
This commit is contained in:
1 parent
1b9ce995ba
commit
6e0ec29888
20 files changed
+427
-53
No files matched your search
@@ -90,17 +90,23 @@ func TestAuthRequiredAndLoginFlow(t *testing.T) {
|
||||
t.Fatalf("login errado: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Login correto: cookie HttpOnly emitido.
|
||||
// Login correto: cookie HttpOnly com ID DE SESSÃO opaco (nunca o token mestre).
|
||||
resp = do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login: %s", resp.Status)
|
||||
}
|
||||
sc := resp.Header.Get("Set-Cookie")
|
||||
if !strings.Contains(sc, "notas_token="+testToken) || !strings.Contains(sc, "HttpOnly") ||
|
||||
if !strings.HasPrefix(sc, "notas_token=") || !strings.Contains(sc, "HttpOnly") ||
|
||||
strings.Contains(sc, "Max-Age=0") {
|
||||
t.Errorf("Set-Cookie inadequado: %q", sc)
|
||||
}
|
||||
cookieValue := strings.SplitN(strings.TrimPrefix(sc, "notas_token="), ";", 2)[0]
|
||||
if cookieValue == testToken || strings.Contains(sc, testToken) {
|
||||
t.Errorf("cookie não deveria carregar o token mestre: %q", sc)
|
||||
}
|
||||
if len(cookieValue) != 64 {
|
||||
t.Errorf("id de sessão deveria ter 64 hex chars (32 bytes), tem %d", len(cookieValue))
|
||||
}
|
||||
|
||||
// /api/me com o cookie.
|
||||
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+cookieValue)
|
||||
@@ -472,3 +478,72 @@ func TestInvalidPayloads(t *testing.T) {
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestLoginRateLimitPerIP(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
resp := do(t, srv, "POST", "/api/login", `{"token":"errada"}`, "", "")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("tentativa %d: esperava 401, obtive %s", i+1, resp.Status)
|
||||
}
|
||||
}
|
||||
// 6ª tentativa errada: bloqueada por IP
|
||||
resp := do(t, srv, "POST", "/api/login", `{"token":"errada"}`, "", "")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("esperava 429 após 5 falhas, obtive %s", resp.Status)
|
||||
}
|
||||
// bloqueio vale até para o token CORRETO (o IP está penalizado)
|
||||
resp = do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("esperava 429 mesmo com token correto, obtive %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRevokedOnLogoutAndBadSessionRejected(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp := do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
|
||||
sc := resp.Header.Get("Set-Cookie")
|
||||
resp.Body.Close()
|
||||
sid := strings.SplitN(strings.TrimPrefix(sc, "notas_token="), ";", 2)[0]
|
||||
|
||||
// sessão válida autentica
|
||||
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+sid)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sessão válida: %s", resp.Status)
|
||||
}
|
||||
// token mestre cru NO cookie não vale mais (cookie = só id de sessão)
|
||||
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+testToken)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("token mestre no cookie deveria ser rejeitado, obtive %s", resp.Status)
|
||||
}
|
||||
// token mestre via Bearer continua valendo (scripts/CLI)
|
||||
resp = do(t, srv, "GET", "/api/me", "", testToken, "")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("bearer com token mestre: %s", resp.Status)
|
||||
}
|
||||
// logout revoga a sessão
|
||||
resp = do(t, srv, "POST", "/api/logout", "", "", "notas_token="+sid)
|
||||
resp.Body.Close()
|
||||
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+sid)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("sessão após logout deveria estar revogada, obtive %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsOversizeInput(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
big := strings.Repeat("x", 512<<10+1)
|
||||
body := `{"content":"` + big + `","format":"md"}`
|
||||
resp := do(t, srv, "POST", "/api/render", body, testToken, "")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("esperava 413 em render >512KiB, obtive %s", resp.Status)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type categoryInput struct {
|
||||
|
||||
func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
var in categoryInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleUpdateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
var in categoryInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
|
||||
+12
-5
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/lgcosta/notas-online/internal/store"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -9,10 +11,15 @@ import (
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleFileUpload recebe multipart/form-data com campo "file" e parâmetros
|
||||
// opcionais folder_id e note_id.
|
||||
// opcionais folder_id e note_id. O corpo inteiro é limitado a MaxFileBytes +
|
||||
// folga de multipart (64 MiB + 64 KiB): sem o cap, o parser do multipart
|
||||
// grava o corpo inteiro em temporários antes de qualquer checagem.
|
||||
const maxUploadBody = store.MaxFileBytes + 64<<10
|
||||
|
||||
func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBody)
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "multipart inválido: "+err.Error())
|
||||
writeError(w, http.StatusBadRequest, "multipart inválido ou excede limite: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
@@ -65,7 +72,7 @@ type fileMoveInput struct {
|
||||
func (s *Server) handleFileMove(w http.ResponseWriter, r *http.Request) {
|
||||
var in fileMoveInput
|
||||
if r.ContentLength > 0 {
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
@@ -89,7 +96,7 @@ type folderInput struct {
|
||||
|
||||
func (s *Server) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var in folderInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
@@ -107,7 +114,7 @@ func (s *Server) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleFolderUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
var in folderInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lgcosta/notas-online/internal/store"
|
||||
)
|
||||
|
||||
// auth faz uma chamada autenticada via helper comum.
|
||||
@@ -230,3 +232,27 @@ func TestFilesAPI(t *testing.T) {
|
||||
}
|
||||
noAuth.Body.Close()
|
||||
}
|
||||
|
||||
func TestUploadRejectsOversizeBody(t *testing.T) {
|
||||
// corpo maior que o cap (64 MiB + 64 KiB): MaxBytesReader deve abortar
|
||||
// antes do multipart ser gravado em temporários
|
||||
big := bytes.Repeat([]byte("A"), store.MaxFileBytes+128<<10)
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, _ := w.CreateFormFile("file", "big.bin")
|
||||
_, _ = fw.Write(big)
|
||||
_ = w.Close()
|
||||
|
||||
srv := newTestServer(t)
|
||||
req, _ := http.NewRequest("POST", srv.URL+"/api/files/upload", &buf)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("esperava 400 para corpo acima do cap, obtive %s", resp.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// loginLimiter limita tentativas de login por IP em janela fixa, em memória.
|
||||
// Escopo deliberado: instância única (sem cluster). Atrás de proxy reverso o
|
||||
// RemoteAddr é o do proxy — o limite vira global, o que só torna o limite mais
|
||||
// rígido; NÃO confiamos em X-Forwarded-For (spoofável).
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
fails map[string]*loginBucket
|
||||
max int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
type loginBucket struct {
|
||||
count int
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
func newLoginLimiter(max int, window time.Duration) *loginLimiter {
|
||||
return &loginLimiter{fails: map[string]*loginBucket{}, max: max, window: window}
|
||||
}
|
||||
|
||||
// remoteIP extrai o host de RemoteAddr sem depender de headers do cliente.
|
||||
func remoteIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// allow diz se o IP ainda pode tentar; não consome tentativa.
|
||||
func (l *loginLimiter) allow(ip string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.sweep()
|
||||
b, ok := l.fails[ip]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(b.resetAt) {
|
||||
delete(l.fails, ip)
|
||||
return true
|
||||
}
|
||||
return b.count < l.max
|
||||
}
|
||||
|
||||
// fail registra uma tentativa errada.
|
||||
func (l *loginLimiter) fail(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.sweep()
|
||||
b, ok := l.fails[ip]
|
||||
if !ok || time.Now().After(b.resetAt) {
|
||||
l.fails[ip] = &loginBucket{count: 1, resetAt: time.Now().Add(l.window)}
|
||||
return
|
||||
}
|
||||
b.count++
|
||||
}
|
||||
|
||||
// success limpa o histórico do IP após login correto.
|
||||
func (l *loginLimiter) success(ip string) {
|
||||
l.mu.Lock()
|
||||
delete(l.fails, ip)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// sweep evita crescimento ilimitado do mapa com IPs variados.
|
||||
func (l *loginLimiter) sweep() {
|
||||
if len(l.fails) < 8192 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for ip, b := range l.fails {
|
||||
if now.After(b.resetAt) {
|
||||
delete(l.fails, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var in noteInput
|
||||
if err := decodeJSON(r, &in, maxBody); err != nil {
|
||||
if err := decodeJSON(w, r, &in, maxBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var in noteInput
|
||||
if err := decodeJSON(r, &in, maxBody); err != nil {
|
||||
if err := decodeJSON(w, r, &in, maxBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ type presenceInput struct {
|
||||
// handlePresence recebe o heartbeat {session, notes:[ids abertos]}.
|
||||
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
|
||||
var in presenceInput
|
||||
if err := decodeJSON(r, &in, 1<<16); err != nil {
|
||||
if err := decodeJSON(w, r, &in, 1<<16); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
|
||||
+10
-1
@@ -7,6 +7,11 @@ import (
|
||||
|
||||
const maxBody = 4 << 20 // folga sobre o limite de conteúdo da store (2 MiB)
|
||||
|
||||
// maxRenderInput limita o tamanho do markdown aceito pelo endpoint de render:
|
||||
// conversão goldmark é O(n)–O(n²) por extensão; sem cap, 1 MiB perverso de
|
||||
// entrada produz ~2 MiB de HTML (amplificação medida) e consome CPU.
|
||||
const maxRenderInput = 512 << 10 // 512 KiB
|
||||
|
||||
type renderInput struct {
|
||||
Content string `json:"content"`
|
||||
Format string `json:"format"`
|
||||
@@ -16,10 +21,14 @@ type renderInput struct {
|
||||
// escapado pelo goldmark; a nota em si é sempre renderizada via innerHTML).
|
||||
func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
|
||||
var in renderInput
|
||||
if err := decodeJSON(r, &in, maxBody); err != nil {
|
||||
if err := decodeJSON(w, r, &in, maxBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
if len(in.Content) > maxRenderInput {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "conteúdo para render excede 512 KiB")
|
||||
return
|
||||
}
|
||||
switch in.Format {
|
||||
case "html":
|
||||
// O preview de HTML usa iframe com sandbox no cliente; não renderiza aqui.
|
||||
|
||||
@@ -87,7 +87,7 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var in restoreInput
|
||||
if r.ContentLength > 0 {
|
||||
if err := decodeJSON(r, &in, 1<<16); err != nil {
|
||||
if err := decodeJSON(w, r, &in, 1<<16); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
|
||||
+61
-30
@@ -25,18 +25,29 @@ const cookieMaxAge = 30 * 24 * time.Hour
|
||||
|
||||
// Server agrupa dependências e rotas.
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
token string
|
||||
webFS fs.FS
|
||||
md goldmark.Markdown
|
||||
hub *hub
|
||||
pres *presenceReg
|
||||
st *store.Store
|
||||
token string
|
||||
secure bool // força Secure no cookie mesmo atrás de proxy sem X-Forwarded-Proto
|
||||
webFS fs.FS
|
||||
md goldmark.Markdown
|
||||
hub *hub
|
||||
pres *presenceReg
|
||||
sess *sessionStore
|
||||
login *loginLimiter
|
||||
}
|
||||
|
||||
// Options parametriza o servidor além do token.
|
||||
type Options struct {
|
||||
// SecureCookie força o atributo Secure no cookie de sessão. Use quando
|
||||
// o acesso é sempre por TLS atrás de proxy reverso — não se confia em
|
||||
// X-Forwarded-Proto do cliente para decidir isso.
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
// New cria o servidor da API. webFS é o filesystem embutido do frontend
|
||||
// (raiz contém index.html, css/, js/).
|
||||
func New(st *store.Store, token string, webFS fs.FS) *Server {
|
||||
return &Server{
|
||||
func New(st *store.Store, token string, webFS fs.FS, opts ...Options) *Server {
|
||||
s := &Server{
|
||||
st: st,
|
||||
token: token,
|
||||
webFS: webFS,
|
||||
@@ -46,7 +57,14 @@ func New(st *store.Store, token string, webFS fs.FS) *Server {
|
||||
),
|
||||
hub: newHub(),
|
||||
pres: newPresenceReg(),
|
||||
sess: newSessionStore(cookieMaxAge),
|
||||
// 5 tentativas erradas por IP em 15 minutos; acerto reseta
|
||||
login: newLoginLimiter(5, 15*time.Minute),
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
s.secure = opts[0].SecureCookie
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler monta o mux completo (rotas públicas, autenticadas e estáticas).
|
||||
@@ -136,26 +154,27 @@ func (s *Server) wrap(next http.Handler) http.Handler {
|
||||
// Autenticação
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) presentedToken(r *http.Request) string {
|
||||
if v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
return c.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) validToken(candidate string) bool {
|
||||
if s.token == "" || candidate == "" {
|
||||
// credOK valida o credencial da requisição por canal separado:
|
||||
// - "Authorization: Bearer <token>" → token mestre (scripts/CLI), constant-time;
|
||||
// - cookie notas_token → id de sessão opaco revogável; o token mestre NUNCA
|
||||
// vale como cookie (o cookie pode vazar por XSS de subdomínio/logs).
|
||||
func (s *Server) credOK(r *http.Request) bool {
|
||||
if s.token == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(candidate), []byte(s.token)) == 1
|
||||
if v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
|
||||
return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(v)), []byte(s.token)) == 1
|
||||
}
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
return s.sess.valid(c.Value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// auth valida o credencial por canal (Bearer=token mestre, cookie=sessão).
|
||||
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validToken(s.presentedToken(r)) {
|
||||
if !s.credOK(r) {
|
||||
writeError(w, http.StatusUnauthorized, "token inválido ou ausente")
|
||||
return
|
||||
}
|
||||
@@ -168,36 +187,48 @@ type loginRequest struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ip := remoteIP(r)
|
||||
if !s.login.allow(ip) {
|
||||
writeError(w, http.StatusTooManyRequests, "muitas tentativas; tente novamente mais tarde")
|
||||
return
|
||||
}
|
||||
var req loginRequest
|
||||
if err := decodeJSON(r, &req, 1<<16); err != nil {
|
||||
if err := decodeJSON(w, r, &req, 1<<16); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
if !s.validToken(strings.TrimSpace(req.Token)) {
|
||||
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(req.Token)), []byte(s.token)) != 1 {
|
||||
s.login.fail(ip)
|
||||
slog.Warn("login falhou", "ip", ip)
|
||||
writeError(w, http.StatusUnauthorized, "token inválido")
|
||||
return
|
||||
}
|
||||
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
s.login.success(ip)
|
||||
sid, exp := s.sess.new()
|
||||
secure := s.secure || r.TLS != nil
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: strings.TrimSpace(req.Token),
|
||||
Value: sid,
|
||||
Path: "/",
|
||||
MaxAge: int(cookieMaxAge.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authed": true})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authed": true, "expires_at": exp.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
s.sess.revoke(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authed": false})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validToken(s.presentedToken(r)) {
|
||||
if !s.credOK(r) {
|
||||
writeError(w, http.StatusUnauthorized, "não autenticado")
|
||||
return
|
||||
}
|
||||
@@ -208,9 +239,9 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
// JSON helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func decodeJSON(r *http.Request, v any, maxBytes int64) error {
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any, maxBytes int64) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, maxBytes))
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBytes))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sessionStore mantém sessões de login em memória: o cookie do navegador
|
||||
// carrega um id de sessão opaco — nunca o token mestre. Reiniciar o processo
|
||||
// revoga tudo (o usuário faz login de novo); logout individual revoga na hora.
|
||||
// Não há crypto própria: id vem de crypto/rand.
|
||||
type sessionStore struct {
|
||||
mu sync.Mutex
|
||||
m map[string]time.Time // id → expiração
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func newSessionStore(ttl time.Duration) *sessionStore {
|
||||
return &sessionStore{m: map[string]time.Time{}, ttl: ttl}
|
||||
}
|
||||
|
||||
// new cria uma sessão e devolve (id, expiração).
|
||||
func (ss *sessionStore) new() (string, time.Time) {
|
||||
var buf [32]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
// crypto/rand falhar é condição fatal de sistema; não há fallback seguro
|
||||
panic("crypto/rand indisponível: " + err.Error())
|
||||
}
|
||||
id := hex.EncodeToString(buf[:])
|
||||
exp := time.Now().Add(ss.ttl)
|
||||
ss.mu.Lock()
|
||||
ss.m[id] = exp
|
||||
ss.mu.Unlock()
|
||||
return id, exp
|
||||
}
|
||||
|
||||
// valid confere se o id existe e não expirou; expirados são removidos no passe.
|
||||
func (ss *sessionStore) valid(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
exp, ok := ss.m[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if now.After(exp) {
|
||||
delete(ss.m, id)
|
||||
return false
|
||||
}
|
||||
// limpeza oportunista: mapa cresceu além do saudável → poda expirados
|
||||
if len(ss.m) > 4096 {
|
||||
for k, e := range ss.m {
|
||||
if now.After(e) {
|
||||
delete(ss.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// revoke remove a sessão; devolve true se existia.
|
||||
func (ss *sessionStore) revoke(id string) bool {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
_, ok := ss.m[id]
|
||||
delete(ss.m, id)
|
||||
return ok
|
||||
}
|
||||
Reference in new issue
Block a user