diff --git a/.gitignore b/.gitignore index 00c07a0..71b61b3 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ target/ .env .env.* !.env.example + +### Claude Code ### +.claude/ diff --git a/README.md b/README.md index 1036eea..92b0732 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,22 @@ O objetivo é enxergar cada peça do Spring Security sem ruído. A API sobe em `http://localhost:8080`. O log está em `DEBUG` para o pacote `org.springframework.security`, então dá para acompanhar cada filtro decidindo o que fazer. +## Tela de testes + +Com a API no ar, abra `http://localhost:8080` no navegador. A página `index.html` (servida pelo +próprio Spring Boot, pasta `static/`) deixa escolher um usuário, chamar cada endpoint e ver o +status, o corpo da resposta e o header `Authorization` que foi enviado. Nada ali é protegido: +a proteção está na API que a tela chama. + +## Testando com curl, Postman ou `.http` + +O guia passo a passo com tudo pronto para copiar e colar está em +[docs/GUIA_CURL_POSTMAN.md](docs/GUIA_CURL_POSTMAN.md). Resumo: + +- `./scripts/testar-api.sh` roda todas as chamadas com curl e confere os códigos. +- `postman/SenacSpringSecurity.postman_collection.json` importa no Postman com testes automáticos. +- `docs/requisicoes.http` funciona no VS Code (REST Client) e no IntelliJ. + ## Usuários de exemplo | Login | Senha | Papéis | diff --git a/docs/GUIA_CURL_POSTMAN.md b/docs/GUIA_CURL_POSTMAN.md new file mode 100644 index 0000000..06b70ec --- /dev/null +++ b/docs/GUIA_CURL_POSTMAN.md @@ -0,0 +1,143 @@ +# Guia passo a passo: testando a API com curl, Postman ou `.http` + +Tudo aqui é copiar e colar. Antes de começar, suba a API: + +```bash +./mvnw spring-boot:run +``` + +Ela responde em `http://localhost:8080`. Usuários disponíveis: + +| Login | Senha | Papéis | +|-------------|------------|------------------| +| `aluno` | `aluno123` | ALUNO | +| `professor` | `prof123` | PROFESSOR | +| `admin` | `admin123` | ADMIN, PROFESSOR | + +O que observar em cada resposta: + +- **200** → autenticado (quando exigido) e autorizado. +- **401 Unauthorized** → falha de **autenticação**: sem credencial, senha errada ou usuário inexistente. +- **403 Forbidden** → falha de **autorização**: login certo, mas o papel não permite. + +--- + +## Opção 1: curl + +O `-i` mostra o status e os headers. O `-u login:senha` monta o header +`Authorization: Basic base64(login:senha)` por você. + +### Passo 1: rota pública (sem login) + +```bash +curl -i http://localhost:8080/api/publico +``` +Esperado: `200`. + +### Passo 2: rota protegida sem credenciais + +```bash +curl -i http://localhost:8080/api/perfil +``` +Esperado: `401`. Repare que não vem o header `WWW-Authenticate`: a API responde "seco", sem pedir popup de login. + +### Passo 3: senha errada e usuário inexistente + +```bash +curl -i -u aluno:senha-errada http://localhost:8080/api/perfil +``` +```bash +curl -i -u ninguem:qualquer http://localhost:8080/api/perfil +``` +Esperado: `401` nos dois. A resposta é igual de propósito, para não revelar quais logins existem. + +### Passo 4: login correto, ver o perfil + +```bash +curl -i -u aluno:aluno123 http://localhost:8080/api/perfil +``` +Esperado: `200` com `{"login":"aluno","papeis":["ROLE_ALUNO"]}`. Note o prefixo `ROLE_`. + +### Passo 5: autorização por URL, papel a papel + +Aluno: +```bash +curl -i -u aluno:aluno123 http://localhost:8080/api/aluno +``` +```bash +curl -i -u aluno:aluno123 http://localhost:8080/api/professor +``` +```bash +curl -i -u aluno:aluno123 http://localhost:8080/api/admin +``` +Esperado: `200`, `403`, `403`. + +Professor: +```bash +curl -i -u professor:prof123 http://localhost:8080/api/professor +``` +```bash +curl -i -u professor:prof123 http://localhost:8080/api/admin +``` +Esperado: `200`, `403`. + +Admin: +```bash +curl -i -u admin:admin123 http://localhost:8080/api/admin +``` +Esperado: `200`. + +### Passo 6: autorização por método (`@PreAuthorize`) + +```bash +curl -i -u aluno:aluno123 http://localhost:8080/api/notas/aluno +``` +```bash +curl -i -u aluno:aluno123 http://localhost:8080/api/notas/outro +``` +```bash +curl -i -u professor:prof123 http://localhost:8080/api/notas/aluno +``` +Esperado: `200` (as próprias notas), `403` (notas de outro), `200` (professor vê qualquer um). + +### Passo 7: montar o header na mão + +Para ver o que o `-u` faz por baixo dos panos: + +```bash +echo -n 'admin:admin123' | base64 +``` +```bash +curl -i -H 'Authorization: Basic YWRtaW46YWRtaW4xMjM=' http://localhost:8080/api/admin +``` +Base64 **não é criptografia**: qualquer um decodifica. Por isso HTTP Basic só faz sentido com HTTPS. + +### Rodar tudo de uma vez + +O script abaixo executa todas as chamadas acima e confere o status de cada uma: + +```bash +./scripts/testar-api.sh +``` + +--- + +## Opção 2: Postman + +1. Abra o Postman e clique em **Import**. +2. Arraste o arquivo [`postman/SenacSpringSecurity.postman_collection.json`](../postman/SenacSpringSecurity.postman_collection.json). +3. A coleção já vem com a variável `baseUrl = http://localhost:8080` e com **testes automáticos** + em cada requisição (aba *Tests*), conferindo o status esperado. +4. Abra cada requisição e clique em **Send**; o resultado dos testes aparece na aba *Test Results*. +5. Para rodar tudo: clique com o botão direito na coleção → **Run collection** → **Run**. + Todas as 17 requisições devem passar. + +Como a autenticação está configurada no Postman: cada requisição usa **Auth → Basic Auth** +com login e senha preenchidos. Veja a aba *Authorization* de qualquer requisição para trocar o usuário. + +--- + +## Opção 3: arquivo `.http` (VS Code ou IntelliJ) + +Abra [`docs/requisicoes.http`](requisicoes.http). No VS Code instale a extensão **REST Client**; +no IntelliJ o suporte é nativo. Clique em **Send Request** acima de cada bloco. diff --git a/docs/requisicoes.http b/docs/requisicoes.http new file mode 100644 index 0000000..b12ed26 --- /dev/null +++ b/docs/requisicoes.http @@ -0,0 +1,61 @@ +### Requisições da API SenacSpringSecurity +### VS Code: extensão "REST Client" | IntelliJ: suporte nativo +@baseUrl = http://localhost:8080 + +### 1. Rota pública, sem login -> 200 +GET {{baseUrl}}/api/publico + +### 2. Rota protegida sem credenciais -> 401 +GET {{baseUrl}}/api/perfil + +### 3. Senha errada -> 401 +GET {{baseUrl}}/api/perfil +Authorization: Basic aluno senha-errada + +### 4. Usuário inexistente -> 401 +GET {{baseUrl}}/api/perfil +Authorization: Basic ninguem qualquer + +### 5. Perfil do aluno -> 200 +GET {{baseUrl}}/api/perfil +Authorization: Basic aluno aluno123 + +### 6. Aluno na área do aluno -> 200 +GET {{baseUrl}}/api/aluno +Authorization: Basic aluno aluno123 + +### 7. Aluno na área do professor -> 403 +GET {{baseUrl}}/api/professor +Authorization: Basic aluno aluno123 + +### 8. Aluno na área admin -> 403 +GET {{baseUrl}}/api/admin +Authorization: Basic aluno aluno123 + +### 9. Professor na área do professor -> 200 +GET {{baseUrl}}/api/professor +Authorization: Basic professor prof123 + +### 10. Professor na área admin -> 403 +GET {{baseUrl}}/api/admin +Authorization: Basic professor prof123 + +### 11. Admin na área admin -> 200 +GET {{baseUrl}}/api/admin +Authorization: Basic admin admin123 + +### 12. Aluno vê as próprias notas -> 200 +GET {{baseUrl}}/api/notas/aluno +Authorization: Basic aluno aluno123 + +### 13. Aluno tenta ver notas de outro -> 403 +GET {{baseUrl}}/api/notas/outro +Authorization: Basic aluno aluno123 + +### 14. Professor vê notas de qualquer aluno -> 200 +GET {{baseUrl}}/api/notas/aluno +Authorization: Basic professor prof123 + +### 15. Header montado na mão (admin:admin123 em base64) -> 200 +GET {{baseUrl}}/api/admin +Authorization: Basic YWRtaW46YWRtaW4xMjM= diff --git a/postman/SenacSpringSecurity.postman_collection.json b/postman/SenacSpringSecurity.postman_collection.json new file mode 100644 index 0000000..4cf146e --- /dev/null +++ b/postman/SenacSpringSecurity.postman_collection.json @@ -0,0 +1,791 @@ +{ + "info": { + "name": "SenacSpringSecurity", + "description": "Fluxo de autenticação e autorização com Spring Security. Suba a API com ./mvnw spring-boot:run e rode a coleção (Run collection).", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080", + "type": "string" + } + ], + "item": [ + { + "name": "1. Autenticação (401 x 200)", + "item": [ + { + "name": "Rota pública sem login -> 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/publico", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "publico" + ] + }, + "description": "permitAll(): ninguém precisa se identificar.", + "auth": { + "type": "noauth" + } + } + }, + { + "name": "Rota protegida sem credenciais -> 401", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 401\", function () {", + " pm.response.to.have.status(401);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/perfil", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "perfil" + ] + }, + "description": "Sem header Authorization o filtro nem chega a autenticar.", + "auth": { + "type": "noauth" + } + } + }, + { + "name": "Senha errada -> 401", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 401\", function () {", + " pm.response.to.have.status(401);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/perfil", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "perfil" + ] + }, + "description": "PasswordEncoder.matches devolveu false.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "aluno", + "type": "string" + }, + { + "key": "password", + "value": "senha-errada", + "type": "string" + } + ] + } + } + }, + { + "name": "Usuário inexistente -> 401", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 401\", function () {", + " pm.response.to.have.status(401);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/perfil", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "perfil" + ] + }, + "description": "UserDetailsService lançou UsernameNotFoundException; resposta igual à de senha errada.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "ninguem", + "type": "string" + }, + { + "key": "password", + "value": "qualquer", + "type": "string" + } + ] + } + } + }, + { + "name": "Credenciais corretas -> 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test(\"Perfil traz login e papel com prefixo ROLE_\", function () {", + " const corpo = pm.response.json();", + " pm.expect(corpo.login).to.eql(\"aluno\");", + " pm.expect(corpo.papeis).to.include(\"ROLE_ALUNO\");", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/perfil", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "perfil" + ] + }, + "description": "Authentication montado e guardado no SecurityContextHolder.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "aluno", + "type": "string" + }, + { + "key": "password", + "value": "aluno123", + "type": "string" + } + ] + } + } + } + ] + }, + { + "name": "2. Autorização por URL (200 x 403)", + "item": [ + { + "name": "Aluno em /api/aluno -> 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/aluno", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "aluno" + ] + }, + "description": "", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "aluno", + "type": "string" + }, + { + "key": "password", + "value": "aluno123", + "type": "string" + } + ] + } + } + }, + { + "name": "Aluno em /api/professor -> 403", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 403\", function () {", + " pm.response.to.have.status(403);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/professor", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "professor" + ] + }, + "description": "", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "aluno", + "type": "string" + }, + { + "key": "password", + "value": "aluno123", + "type": "string" + } + ] + } + } + }, + { + "name": "Aluno em /api/admin -> 403", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 403\", function () {", + " pm.response.to.have.status(403);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/admin", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "admin" + ] + }, + "description": "", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "aluno", + "type": "string" + }, + { + "key": "password", + "value": "aluno123", + "type": "string" + } + ] + } + } + }, + { + "name": "Professor em /api/aluno -> 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/aluno", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "aluno" + ] + }, + "description": "", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "professor", + "type": "string" + }, + { + "key": "password", + "value": "prof123", + "type": "string" + } + ] + } + } + }, + { + "name": "Professor em /api/professor -> 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/professor", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "professor" + ] + }, + "description": "", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "professor", + "type": "string" + }, + { + "key": "password", + "value": "prof123", + "type": "string" + } + ] + } + } + }, + { + "name": "Professor em /api/admin -> 403", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 403\", function () {", + " pm.response.to.have.status(403);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/admin", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "admin" + ] + }, + "description": "", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "professor", + "type": "string" + }, + { + "key": "password", + "value": "prof123", + "type": "string" + } + ] + } + } + }, + { + "name": "Admin: perfil com dois papéis -> 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test(\"Perfil traz login e papel com prefixo ROLE_\", function () {", + " const corpo = pm.response.json();", + " pm.expect(corpo.login).to.eql(\"admin\");", + " pm.expect(corpo.papeis).to.include(\"ROLE_ADMIN\");", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/perfil", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "perfil" + ] + }, + "description": "", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin", + "type": "string" + }, + { + "key": "password", + "value": "admin123", + "type": "string" + } + ] + } + } + }, + { + "name": "Admin em /api/admin -> 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/admin", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "admin" + ] + }, + "description": "", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin", + "type": "string" + }, + { + "key": "password", + "value": "admin123", + "type": "string" + } + ] + } + } + } + ] + }, + { + "name": "3. Autorização por método (@PreAuthorize)", + "item": [ + { + "name": "Aluno vê as próprias notas -> 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/notas/aluno", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "notas", + "aluno" + ] + }, + "description": "#aluno == authentication.name", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "aluno", + "type": "string" + }, + { + "key": "password", + "value": "aluno123", + "type": "string" + } + ] + } + } + }, + { + "name": "Aluno tenta ver notas de outro -> 403", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 403\", function () {", + " pm.response.to.have.status(403);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/notas/outro", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "notas", + "outro" + ] + }, + "description": "", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "aluno", + "type": "string" + }, + { + "key": "password", + "value": "aluno123", + "type": "string" + } + ] + } + } + }, + { + "name": "Professor vê notas de qualquer aluno -> 200", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/notas/aluno", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "notas", + "aluno" + ] + }, + "description": "hasAnyRole('PROFESSOR','ADMIN')", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "professor", + "type": "string" + }, + { + "key": "password", + "value": "prof123", + "type": "string" + } + ] + } + } + }, + { + "name": "Notas sem login -> 401", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status deve ser 401\", function () {", + " pm.response.to.have.status(401);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/notas/aluno", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "notas", + "aluno" + ] + }, + "description": "", + "auth": { + "type": "noauth" + } + } + } + ] + } + ] +} \ No newline at end of file diff --git a/scripts/testar-api.sh b/scripts/testar-api.sh new file mode 100755 index 0000000..550071c --- /dev/null +++ b/scripts/testar-api.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Executa todas as chamadas do guia e confere o status HTTP de cada uma. +# Uso: ./scripts/testar-api.sh [baseUrl] (padrão: http://localhost:8080) +set -u + +BASE="${1:-http://localhost:8080}" +VERDE='\033[0;32m'; VERMELHO='\033[0;31m'; SEM_COR='\033[0m' +falhas=0 + +# chamada [login:senha] +chamada() { + local esperado="$1" descricao="$2" url="$3" cred="${4:-}" + local status + if [ -n "$cred" ]; then + status=$(curl -s -o /dev/null -w '%{http_code}' -u "$cred" "$BASE$url") + else + status=$(curl -s -o /dev/null -w '%{http_code}' "$BASE$url") + fi + if [ "$status" = "$esperado" ]; then + printf "${VERDE}OK ${SEM_COR} %s %-22s %-16s -> %s\n" "$esperado" "$url" "${cred%%:*}" "$descricao" + else + printf "${VERMELHO}FALHOU${SEM_COR} esperado %s, veio %s %s (%s)\n" "$esperado" "$status" "$url" "${cred:-sem login}" + falhas=$((falhas + 1)) + fi +} + +if ! curl -s -o /dev/null "$BASE/api/publico"; then + echo "API não respondeu em $BASE. Suba com ./mvnw spring-boot:run" + exit 1 +fi + +echo "== Autenticação ==" +chamada 200 "rota pública sem login" /api/publico +chamada 401 "sem credenciais" /api/perfil +chamada 401 "senha errada" /api/perfil aluno:senha-errada +chamada 401 "usuário inexistente" /api/perfil ninguem:qualquer +chamada 200 "credenciais corretas" /api/perfil aluno:aluno123 + +echo "== Autorização por URL ==" +chamada 200 "aluno na área do aluno" /api/aluno aluno:aluno123 +chamada 403 "aluno na área do professor" /api/professor aluno:aluno123 +chamada 403 "aluno na área admin" /api/admin aluno:aluno123 +chamada 200 "professor na área do aluno" /api/aluno professor:prof123 +chamada 200 "professor na área do professor" /api/professor professor:prof123 +chamada 403 "professor na área admin" /api/admin professor:prof123 +chamada 200 "admin na área do aluno" /api/aluno admin:admin123 +chamada 200 "admin na área do professor" /api/professor admin:admin123 +chamada 200 "admin na área admin" /api/admin admin:admin123 + +echo "== Autorização por método (@PreAuthorize) ==" +chamada 200 "aluno vê as próprias notas" /api/notas/aluno aluno:aluno123 +chamada 403 "aluno vê notas de outro" /api/notas/outro aluno:aluno123 +chamada 200 "professor vê notas de qualquer um" /api/notas/aluno professor:prof123 +chamada 401 "notas sem login" /api/notas/aluno + +echo +if [ "$falhas" -eq 0 ]; then + printf "${VERDE}Todas as chamadas responderam como esperado.${SEM_COR}\n" +else + printf "${VERMELHO}%d chamada(s) com resultado inesperado.${SEM_COR}\n" "$falhas" + exit 1 +fi diff --git a/src/main/java/br/senac/xanxere/seguranca/config/SecurityConfig.java b/src/main/java/br/senac/xanxere/seguranca/config/SecurityConfig.java index 4cc7d1a..467ae7b 100644 --- a/src/main/java/br/senac/xanxere/seguranca/config/SecurityConfig.java +++ b/src/main/java/br/senac/xanxere/seguranca/config/SecurityConfig.java @@ -39,6 +39,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // ---------- AUTORIZAÇÃO por URL ---------- // As regras são avaliadas na ordem: a primeira que casar com a URL vence. .authorizeHttpRequests(auth -> auth + // A tela de testes (index.html) é estática e pública; a proteção fica na API que ela chama. + .requestMatchers("/", "/index.html").permitAll() .requestMatchers("/api/publico").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/professor/**").hasAnyRole("PROFESSOR", "ADMIN") diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..7fabab0 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,211 @@ + + + + + + SenacSpringSecurity + + + +
+

SenacSpringSecurity

+

Escolha um usuário, chame um endpoint e veja se a resposta é 200 (autorizado), 401 (não autenticado) ou 403 (autenticado, mas sem permissão).

+
+ +
+
+

1. Autenticação (quem é você?)

+ + + + +
+ + + + +
+
+ + +
+
Sem credenciais. As chamadas sairão sem o header Authorization.
+
+ Como funciona + Não existe "sessão": o navegador guarda login e senha em memória e envia o header + Authorization: Basic base64(login:senha) em cada chamada. "Entrar" só chama + /api/perfil para confirmar que as credenciais valem. "Sair" apaga as credenciais. +
+
+ +
+

2. Autorização (o que você pode fazer?)

+
+ + + + + +
+ +
+ + +
+
+ +
+

Resposta

+
Nenhuma chamada ainda.
+ +
+ +
+

Histórico

+ + + +
#UsuárioEndpointStatusSignificado
+
+
+ + + + diff --git a/src/test/java/br/senac/xanxere/seguranca/AutenticacaoTest.java b/src/test/java/br/senac/xanxere/seguranca/AutenticacaoTest.java index 40f846d..59a907b 100644 --- a/src/test/java/br/senac/xanxere/seguranca/AutenticacaoTest.java +++ b/src/test/java/br/senac/xanxere/seguranca/AutenticacaoTest.java @@ -2,6 +2,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -11,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; /** @@ -33,6 +35,14 @@ void rotaPublicaNaoExigeLogin() throws Exception { .andExpect(jsonPath("$.mensagem").exists()); } + @Test + @DisplayName("tela de testes (index.html) é pública") + void telaDeTestesEhPublica() throws Exception { + mvc.perform(get("/index.html")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)); + } + @Test @DisplayName("rota protegida sem credenciais responde 401") void semCredenciaisRetorna401() throws Exception {