Java Spring Boot Segurança
Módulo 4 — Java

Optional & Tratamento de Erros

Duas das fontes mais comuns de bugs em aplicações Java são a ausência de valores onde se espera um valor — o infame NullPointerException — e a propagação silenciosa de erros até ao cliente de formas inconsistentes ou informativamente perigosas. Este módulo trata os dois problemas: Optional para tornar a ausência explícita e segura, e uma arquitectura de tratamento de erros que garante respostas consistentes, sem vazar informação de sistema para o exterior.

O Problema do Null

Em C, um ponteiro pode ser NULL — e é tua responsabilidade verificar antes de desreferenciar. Se não o fizeres, o programa termina com segmentation fault. O compilador não te obriga a verificar; a disciplina é tua.

Em Java, qualquer referência a um objecto pode ser null. Chamar um método numa referência nula lança uma NullPointerException em runtime — sem aviso em compile-time. Tony Hoare, o inventor do conceito de null, chamou-lhe o "billion-dollar mistake": décadas de bugs, crashes e vulnerabilidades de segurança causados por uma única decisão de design.

// O problema clássico
public Document getDocument(Long id) {
    Document doc = repository.findById(id); // pode retornar null
    return doc.withAuditStamp();            // NullPointerException se doc == null
    // o compilador não avisa. o erro só aparece em runtime, em produção.
}

// A "solução" ingénua — verificações null em cascata
public Document getDocument(Long id) {
    Document doc = repository.findById(id);
    if (doc == null) {
        return null; // o chamador também tem de verificar null...
    }
    AuditStamp stamp = auditService.getStamp(id);
    if (stamp == null) {
        return doc;
    }
    return doc.withAuditStamp(stamp);
    // código defensivo que obscurece a lógica real
}

Optional<T>: Ausência Explícita

Optional<T> é um contentor que representa um valor que pode ou não estar presente. Em vez de retornar null quando algo não existe, retornas um Optional.empty(). O tipo do retorno comunica ao compilador — e ao programador — que a ausência é um caso legítimo que tem de ser tratado.

// Criação de Optional
Optional<Document> presente  = Optional.of(document);       // valor garantido não-null
Optional<Document> talvez    = Optional.ofNullable(value);  // pode ser null — usa sempre este com dados externos
Optional<Document> vazio     = Optional.empty();            // ausência explícita

// Optional.of() lança NullPointerException se o valor for null.
// Optional.ofNullable() converte null em Optional.empty() sem excepção.
// Regra: quando o valor vem de uma fonte externa (BD, API, input), usar ofNullable.
// Consumir um Optional — as formas correctas

Optional<Document> result = repository.findByIdAndOwnerId(id, ownerId);

// 1. orElseThrow — o mais usado em APIs REST
//    se presente: retorna o valor
//    se vazio: lança a excepção fornecida
Document doc = result.orElseThrow(
    () -> new ResourceNotFoundException("Document not found")
);

// 2. orElse — valor por omissão
Document doc = result.orElse(Document.empty());

// 3. orElseGet — valor por omissão computado lazily (preferível a orElse para objectos pesados)
Document doc = result.orElseGet(() -> documentFactory.createDefault());

// 4. ifPresent — executar uma acção apenas se o valor existir
result.ifPresent(doc -> auditService.log("accessed", doc.getId()));

// 5. ifPresentOrElse — branch explícito para ambos os casos (Java 9+)
result.ifPresentOrElse(
    doc -> auditService.log("accessed", doc.getId()),
    ()  -> auditService.log("not found", id)
);

// 6. map — transformar o valor se presente
Optional<String> title = result.map(Document::getTitle);

// 7. flatMap — transformar quando a função também retorna Optional
Optional<Owner> owner = result.flatMap(doc -> userRepository.findById(doc.getOwnerId()));

Anti-Padrões do Optional

Optional foi desenhado para ser o tipo de retorno de métodos que podem não ter resultado. Usá-lo fora desse contexto cria código mais complexo sem benefício.

// ❌ Anti-padrão 1: optional.get() sem verificação prévia
// Equivalente a desreferenciar um ponteiro sem verificar null — derrota o propósito
Optional<Document> result = repository.findById(id);
Document doc = result.get(); // lança NoSuchElementException se vazio — pior que null

// ✅ Correcto: usar orElseThrow com excepção de domínio
Document doc = result.orElseThrow(() -> new ResourceNotFoundException("Document not found"));

// ❌ Anti-padrão 2: Optional como campo de classe
// Optional não é Serializable — quebra JPA, Jackson, e outros frameworks
public class Document {
    private Optional<String> description; // não fazer isto
}

// ✅ Correcto: campo nullable, Optional apenas no retorno do getter
public class Document {
    private String description; // pode ser null internamente

    public Optional<String> getDescription() {
        return Optional.ofNullable(description); // Optional apenas na interface pública
    }
}

// ❌ Anti-padrão 3: Optional como parâmetro de método
// Força o chamador a embrulhar valores em Optional — verboso e inútil
public Document findDocument(Optional<Long> id) { ... }

// ✅ Correcto: parâmetro nullable com @Nullable, ou dois métodos distintos
public Optional<Document> findDocument(Long id) { ... }
public Optional<Document> findDocumentByTitle(String title) { ... }

// ❌ Anti-padrão 4: isPresent() + get() em vez de orElseThrow/map
if (result.isPresent()) {
    return result.get(); // verboso — usa orElseThrow directamente
} else {
    throw new ResourceNotFoundException("not found");
}

// ✅ Correcto: encadear directamente
return result.orElseThrow(() -> new ResourceNotFoundException("not found"));

Excepções: Checked vs Unchecked

Java distingue dois tipos de excepções. As checked são verificadas pelo compilador — um método que as lança tem de as declarar na assinatura com throws, e quem o chama tem de as tratar ou propagar. As unchecked (subclasses de RuntimeException) não são verificadas pelo compilador — podem ser lançadas e propagadas livremente.

TipoHierarquiaVerificaçãoQuando usar
Checked Exception (não RuntimeException) Compilador obriga a tratar ou declarar Erros recuperáveis esperados: ficheiro não encontrado, falha de rede. O chamador pode fazer algo útil.
Unchecked RuntimeException Nenhuma — propagam livremente Erros de programação ou condições de negócio: argumento inválido, recurso não encontrado, acesso não autorizado.
// A hierarquia de excepções Java relevante para Spring Boot:
//
// Throwable
// ├── Error                    (erros da JVM — não apanhar: OutOfMemoryError, StackOverflowError)
// └── Exception
//     ├── IOException          (checked — ficheiros, rede)
//     ├── SQLException         (checked — JDBC directo)
//     └── RuntimeException     (unchecked — base das excepções de aplicação)
//         ├── NullPointerException
//         ├── IllegalArgumentException
//         ├── IllegalStateException
//         └── (as tuas excepções de domínio herdam daqui)

// Spring Boot traduz SQLException para DataAccessException (unchecked)
// via o mecanismo do @Repository — nunca apanhas SQLException directamente em Spring.
Spring prefere unchecked O Spring Boot usa quase exclusivamente RuntimeException para excepções de aplicação. A razão é pragmática: checked exceptions obrigam a declarar throws em cada camada da pilha de chamadas, poluindo as assinaturas dos métodos com detalhes de implementação. Em aplicações web, os erros são tratados centralmente pelo @ControllerAdvice — não em cada método individual.

Excepções de Domínio

Em vez de usar excepções genéricas como IllegalArgumentException ou strings de erro hardcoded, cria excepções de domínio específicas para a tua aplicação. Cada excepção representa uma condição de negócio clara e mapeia directamente para um HTTP status code.

// Excepção base da aplicação — todas as excepções de negócio herdam daqui
public abstract class ApplicationException extends RuntimeException {
    private final int statusCode;

    protected ApplicationException(String message, int statusCode) {
        super(message);
        this.statusCode = statusCode;
    }

    public int getStatusCode() { return statusCode; }
}

// 404 — recurso não encontrado (também usado para IDOR — ver mais abaixo)
public class ResourceNotFoundException extends ApplicationException {
    public ResourceNotFoundException(String message) {
        super(message, 404);
    }
}

// 401 — não autenticado (sem token ou token inválido)
public class UnauthorizedException extends ApplicationException {
    public UnauthorizedException(String message) {
        super(message, 401);
    }
}

// 403 — autenticado mas sem permissão (role insuficiente)
public class ForbiddenException extends ApplicationException {
    public ForbiddenException(String message) {
        super(message, 403);
    }
}

// 409 — conflito de negócio (email já registado, estado inválido)
public class ConflictException extends ApplicationException {
    public ConflictException(String message) {
        super(message, 409);
    }
}

// 422 — entidade não processável (validação de negócio, não de formato)
public class UnprocessableEntityException extends ApplicationException {
    public UnprocessableEntityException(String message) {
        super(message, 422);
    }
}

// Uso nos serviços:
public Account getAccount(Long accountId, Long authenticatedUserId) {
    return repository.findByIdAndOwnerId(accountId, authenticatedUserId)
        .orElseThrow(() -> new ResourceNotFoundException("Account not found"));
        // nota: não dizemos "Account not found OR not yours" — ver secção de segurança
}

@ControllerAdvice: Tratamento Global de Erros

Sem tratamento centralizado, cada excepção não apanhada propaga até ao Spring Boot, que retorna uma resposta de erro genérica com stack traces expostos ou HTML de erro por omissão — inaceitável em produção. O @ControllerAdvice define handlers globais que interceptam excepções em qualquer controller e produzem respostas JSON consistentes.

// Modelo de resposta de erro — estrutura consistente em toda a API
public record ErrorResponse(
    String timestamp,   // quando ocorreu o erro
    int status,         // HTTP status code
    String error,       // descrição do status (ex: "Not Found")
    String message,     // mensagem para o cliente — sem detalhes internos
    String path         // endpoint que gerou o erro
) {
    public static ErrorResponse of(int status, String error,
                                    String message, String path) {
        return new ErrorResponse(
            Instant.now().toString(), status, error, message, path
        );
    }
}

// Handler global de excepções
@RestControllerAdvice  // = @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {

    // Apanhar todas as excepções de domínio numa só regra
    @ExceptionHandler(ApplicationException.class)
    public ResponseEntity<ErrorResponse> handleApplicationException(
            ApplicationException ex, HttpServletRequest request) {

        HttpStatus status = HttpStatus.resolve(ex.getStatusCode());
        ErrorResponse body = ErrorResponse.of(
            ex.getStatusCode(),
            status != null ? status.getReasonPhrase() : "Error",
            ex.getMessage(),
            request.getRequestURI()
        );
        return ResponseEntity.status(ex.getStatusCode()).body(body);
    }

    // Erros de validação (@Valid falhou)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex, HttpServletRequest request) {

        // agregar todas as mensagens de validação numa só string
        String message = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.joining(", "));

        ErrorResponse body = ErrorResponse.of(400, "Bad Request", message, request.getRequestURI());
        return ResponseEntity.badRequest().body(body);
    }

    // Fallback — qualquer excepção não prevista
    // IMPORTANTE: nunca expor ex.getMessage() aqui — pode conter detalhes internos
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex, HttpServletRequest request) {

        // log interno com detalhe — o cliente não vê isto
        log.error("Unhandled exception on {}: {}", request.getRequestURI(), ex.getMessage(), ex);

        ErrorResponse body = ErrorResponse.of(
            500, "Internal Server Error",
            "An unexpected error occurred",  // mensagem genérica — sem stack trace
            request.getRequestURI()
        );
        return ResponseEntity.internalServerError().body(body);
    }
}
// Exemplo de resposta JSON para ResourceNotFoundException:
{
  "timestamp": "2026-06-01T10:30:00Z",
  "status": 404,
  "error": "Not Found",
  "message": "Account not found",
  "path": "/api/accounts/42"
}

// Exemplo de resposta para erro de validação:
{
  "timestamp": "2026-06-01T10:31:00Z",
  "status": 400,
  "error": "Bad Request",
  "message": "email: must be a valid email address, password: size must be between 8 and 100",
  "path": "/api/auth/register"
}

Validação com Bean Validation

Bean Validation (JSR-380) permite declarar regras de validação directamente nos DTOs com anotações. O Spring Boot integra-o automaticamente — basta anotar o parâmetro do controller com @Valid e o Spring valida o objecto antes de o entregar ao método.

// DTO de registo com validação declarativa
public record RegisterRequest(

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    String name,

    @NotBlank(message = "Email is required")
    @Email(message = "must be a valid email address")
    String email,

    @NotBlank(message = "Password is required")
    @Size(min = 8, max = 100, message = "Password must be at least 8 characters")
    String password

) {}

// Controller — @Valid activa a validação antes de o método ser executado
@RestController
@RequestMapping("/api/auth")
public class AuthController {
    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/register")
    public ResponseEntity<Void> register(@Valid @RequestBody RegisterRequest request) {
        // se a validação falhar, MethodArgumentNotValidException é lançada
        // o GlobalExceptionHandler apanha-a e retorna 400 com as mensagens
        authService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

// Anotações de validação mais comuns:
// @NotNull       — não pode ser null (qualquer tipo)
// @NotBlank      — não pode ser null, vazio, ou só espaços (String)
// @NotEmpty      — não pode ser null ou vazio (String, Collection, Map, Array)
// @Size          — tamanho entre min e max (String, Collection)
// @Email         — formato de email válido
// @Min / @Max    — valor numérico mínimo / máximo
// @Positive      — número positivo (> 0)
// @PositiveOrZero — número >= 0
// @Pattern       — validação por regex
// @Future        — data no futuro
// @Past          — data no passado

Erros de Segurança: 401 vs 403

Dois status codes de segurança frequentemente confundidos têm semânticas distintas e consequências diferentes para o cliente.

StatusSignificadoQuando retornar
401 Unauthorized Não autenticado O pedido não tem credenciais válidas. Sem token, token expirado, token inválido. O cliente deve autenticar-se.
403 Forbidden Autenticado mas sem permissão O utilizador está autenticado mas o seu role não tem acesso ao recurso. Ex: utilizador normal a aceder a endpoint de admin.
404 Not Found Recurso não existe (ou não é teu) Recurso inexistente ou recurso que existe mas não pertence ao utilizador autenticado. Ver anti-IDOR abaixo.
// Configurar respostas de segurança no Spring Security
// Em vez de redireccionamentos para páginas de login (comportamento MVC),
// uma API REST deve retornar JSON com os status codes correctos.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        // ...
        .exceptionHandling(ex -> ex
            // 401 — pedido sem autenticação válida
            .authenticationEntryPoint((request, response, authException) -> {
                response.setContentType("application/json");
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("""
                    {"status":401,"error":"Unauthorized","message":"Authentication required"}
                """);
            })
            // 403 — autenticado mas sem permissão de role
            .accessDeniedHandler((request, response, accessDeniedException) -> {
                response.setContentType("application/json");
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                response.getWriter().write("""
                    {"status":403,"error":"Forbidden","message":"Insufficient permissions"}
                """);
            })
        )
        .build();
}

Não Vazar Informação em Mensagens de Erro

As mensagens de erro são um vector de information disclosure — um atacante pode usar as diferenças entre respostas de erro para inferir o estado interno do sistema. A regra geral é: mensagens de erro para o cliente devem ser suficientemente informativas para o utilizador legítimo, mas nunca revelar detalhes de implementação, existência de recursos alheios, ou estrutura da base de dados.

// ❌ Mensagens que vazam informação

// Vaza que o email existe no sistema — permite enumeração de utilizadores
throw new UnauthorizedException("Password incorrect for user " + email);

// Vaza que o recurso existe mas pertence a outro utilizador
throw new ForbiddenException("Account 42 belongs to a different user");

// Vaza detalhes da query SQL
throw new RuntimeException("ERROR: column owner_id does not exist at character 45");

// Vaza stack trace para o cliente
response.getWriter().write(ex.getStackTrace().toString());

// ✅ Mensagens seguras

// Login: mesma mensagem para email errado e password errada
throw new UnauthorizedException("Invalid credentials");

// Recurso alheio: mesma mensagem que recurso inexistente
throw new ResourceNotFoundException("Account not found");

// Erros internos: mensagem genérica para o cliente, detalhe no log interno
log.error("Database error fetching account {}: {}", id, ex.getMessage(), ex);
throw new RuntimeException("An unexpected error occurred"); // apanhado pelo GlobalExceptionHandler

O Pattern 404 Anti-IDOR

Este é um dos princípios de segurança mais importantes na arquitectura deste projecto e merece atenção explícita. Quando um utilizador tenta aceder a um recurso que existe na base de dados mas pertence a outro utilizador, há dois comportamentos possíveis:

// Comportamento vulnerável — confirma que o recurso existe
// GET /api/accounts/42 (o utilizador autenticado é o 99, não o dono da conta 42)

// Query vulnerável: apenas verifica existência
SELECT * FROM accounts WHERE id = 42;
// → encontra a conta → retorna 403 Forbidden

// O que o atacante aprende:
// "A conta 42 existe. Não é minha mas existe.
//  Vou tentar explorar outro vector para a aceder."
// Ao iterar IDs (42, 43, 44...) consegue mapear todos os recursos do sistema.
// Comportamento seguro — não confirma que o recurso existe
// GET /api/accounts/42 (o utilizador autenticado é o 99, não o dono da conta 42)

// Query segura: verifica existência E propriedade em simultâneo
SELECT * FROM accounts WHERE id = 42 AND owner_id = 99;
// → 0 resultados (a conta existe mas owner_id é diferente)
// → Optional.empty() → ResourceNotFoundException → 404 Not Found

// O que o atacante aprende:
// "A conta 42 não existe (ou não é minha). Não sei distinguir os dois casos."
// Enumerar IDs torna-se inútil — a resposta é sempre a mesma.
// Implementação completa — da query ao HTTP status

// Repository — a query já incorpora o isolamento lógico
@Repository
public class AccountRepository {
    public Optional<Account> findByIdAndOwnerId(Long id, Long ownerId) {
        return jdbc.query(
            "SELECT * FROM accounts WHERE id = ? AND owner_id = ?",
            new AccountRowMapper(), id, ownerId
        ).stream().findFirst();
        // se id existe mas owner_id não corresponde → Optional.empty()
        // indistinguível de id que não existe
    }
}

// Service — converte Optional.empty() em ResourceNotFoundException
@Service
public class AccountService {
    public Account getAccount(Long accountId, Long authenticatedUserId) {
        return repository.findByIdAndOwnerId(accountId, authenticatedUserId)
            .orElseThrow(() -> new ResourceNotFoundException("Account not found"));
            // mesma excepção, mesma mensagem — independentemente do motivo
    }
}

// GlobalExceptionHandler — converte ResourceNotFoundException em 404
@ExceptionHandler(ApplicationException.class)
public ResponseEntity<ErrorResponse> handleApplicationException(
        ApplicationException ex, HttpServletRequest request) {
    // ResourceNotFoundException tem statusCode = 404
    // a resposta é sempre 404 "Account not found"
    // o atacante não consegue distinguir "não existe" de "não é teu"
}
403 em vez de 404 quebra o isolamento lógico Retornar 403 Forbidden quando o recurso existe mas pertence a outro utilizador confirma a existência do recurso. Um atacante que itera IDs consegue mapear todos os recursos do sistema simplesmente pela diferença entre 404 (não existe) e 403 (existe mas não é teu). A query WHERE id = ? AND owner_id = ? seguida de 404 é a defesa correcta — a informação de propriedade nunca chega ao cliente.

Checklist Optional & Erros