O JWT prova quem é o utilizador. O Cookie garante que o token viaja em segurança. Mas nenhuma destas camadas responde à pergunta mais importante de autorização: este recurso específico pertence a quem está autenticado? Este módulo explica porque a autorização tem de ser estrutural ao nível da query SQL, e como implementá-la em Spring Data JPA de forma que seja impossível de esquecer.
O padrão mais comum de IDOR não resulta de ignorância de segurança — resulta de uma separação incorreta de responsabilidades. O developer valida o JWT corretamente, extrai o user id, e depois faz a query apenas pelo id do recurso, confiando que "quem tem o token válido tem acesso".
// ✗ VULNERÁVEL — autorização ingénua
@GetMapping("/documents/{id}")
public ResponseEntity<Document> getDocument(
@PathVariable Long id,
@AuthenticationPrincipal AuthenticatedUser user) {
// userId está disponível mas não é usado na query
Document doc = documentRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND));
// Verificação tardia — ERRADA: o recurso já foi lido da BD
if (!doc.getOwnerId().equals(user.getId())) {
throw new ResponseStatusException(FORBIDDEN);
}
return ResponseEntity.ok(doc);
}
if e a vulnerabilidade reaparece
silenciosamente. A autorização estrutural na query elimina ambos os problemas.
A query correta filtra por id e owner_id
simultaneamente. Se o recurso não pertence ao utilizador autenticado, a query
devolve zero resultados — e o servidor responde 404.
-- ✓ SEGURO — isolamento lógico na query
-- owner_id = 99 vem do JWT, nunca do cliente
SELECT * FROM documents
WHERE id = 6
AND owner_id = 99;
-- Documento 6 pertence ao user 12 → 0 resultados → 404 Not Found
-- Documento 6 pertence ao user 99 → 1 resultado → 200 OK
-- O atacante não sabe se o documento 6 existe — enumeração impossível
O Spring Data JPA permite expressar este padrão de forma declarativa no repositório.
A assinatura do método torna impossível chamar a query sem fornecer o ownerId
— a autorização fica estruturalmente acoplada à leitura.
@Repository
public interface DocumentRepository extends JpaRepository<Document, Long> {
// Leitura única: só devolve se id E ownerId coincidirem
Optional<Document> findByIdAndOwnerId(Long id, Long ownerId);
// Listagem: só documentos do utilizador autenticado
List<Document> findAllByOwnerId(Long ownerId);
// Update: WHERE id = ? AND owner_id = ? — nunca atualiza o que não é seu
@Modifying
@Query("UPDATE Document d SET d.title = :title, d.content = :content " +
"WHERE d.id = :id AND d.ownerId = :ownerId")
int updateByIdAndOwnerId(@Param("id") Long id,
@Param("ownerId") Long ownerId,
@Param("title") String title,
@Param("content") String content);
// Delete: WHERE id = ? AND owner_id = ? — nunca apaga o que não é seu
@Modifying
@Query("DELETE FROM Document d WHERE d.id = :id AND d.ownerId = :ownerId")
int deleteByIdAndOwnerId(@Param("id") Long id,
@Param("ownerId") Long ownerId);
}
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
private final DocumentRepository repo;
@GetMapping("/{id}")
public ResponseEntity<Document> get(
@PathVariable Long id,
@AuthenticationPrincipal AuthenticatedUser user) {
return repo.findByIdAndOwnerId(id, user.getId())
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PutMapping("/{id}")
public ResponseEntity<Void> update(
@PathVariable Long id,
@RequestBody DocumentUpdateRequest body,
@AuthenticationPrincipal AuthenticatedUser user) {
int updated = repo.updateByIdAndOwnerId(
id, user.getId(), body.title(), body.content());
return updated > 0
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(
@PathVariable Long id,
@AuthenticationPrincipal AuthenticatedUser user) {
int deleted = repo.deleteByIdAndOwnerId(id, user.getId());
return deleted > 0
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}
int nas operações mutáveis
Os métodos @Modifying devolvem o número de linhas afetadas. Se o valor
for 0, o recurso não existe ou não pertence ao utilizador — ambos
os casos mapeiam para 404. Esta convenção evita confirmar ao atacante a existência
do recurso.
O isolamento lógico pressupõe que todas as entidades que requerem autorização por
posse têm uma coluna owner_id com constraint NOT NULL.
-- Schema SQL — owner_id NOT NULL é obrigatório
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índice composto: acelera as queries de isolamento lógico
CREATE INDEX idx_documents_owner ON documents(owner_id, id);
@Entity
@Table(name = "documents")
public class Document {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "owner_id", nullable = false)
private Long ownerId;
@Column(nullable = false)
private String title;
private String content;
}
"Utilizadores com role ADMIN podem aceder a qualquer recurso."
Controlado pelo claim role no JWT. Implementado com
@PreAuthorize ou SecurityFilterChain.
"Utilizadores só acedem ao que lhes pertence." Controlado por
AND owner_id = ? na query. Não existe claim no JWT para
isto — é uma propriedade dos dados.
// Combinação RBAC + OBAC no mesmo endpoint
@GetMapping("/{id}")
public ResponseEntity<Document> get(
@PathVariable Long id,
@AuthenticationPrincipal AuthenticatedUser user) {
// RBAC: admins vêem qualquer documento
if (user.getRole().equals("ADMIN")) {
return repo.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// OBAC: utilizadores regulares só vêem os seus
return repo.findByIdAndOwnerId(id, user.getId())
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
Em sistemas com partilha (ex: documentos colaborativos, equipas), o modelo
owner_id simples não cobre todos os cenários. A extensão natural
é uma tabela de permissões explícitas.
-- Tabela de permissões explícitas para partilha
CREATE TABLE document_permissions (
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission VARCHAR(20) NOT NULL, -- 'READ', 'WRITE', 'ADMIN'
PRIMARY KEY (document_id, user_id)
);
-- Query com permissões explícitas
SELECT d.* FROM documents d
WHERE d.id = 6
AND (
d.owner_id = 99
OR EXISTS (
SELECT 1 FROM document_permissions dp
WHERE dp.document_id = d.id
AND dp.user_id = 99
AND dp.permission IN ('READ', 'WRITE', 'ADMIN')
)
);
| Módulo | Tecnologia | Garante | Depende de |
|---|---|---|---|
| 1 · JWT | HMAC-SHA256 / RSA | Identidade verificável, stateless, escalável | Chave secreta segura, expiração curta |
| 2 · Cookie | HttpOnly + Secure + SameSite | Token opaco ao JS, transporte HTTPS, CSRF mitigado | Módulo 1 para ter algo a transportar |
| 3 · SQL | WHERE id = ? AND owner_id = ? | Autorização por posse, anti-enumeração, anti-IDOR | Módulo 1 para extrair o owner_id do token |