diff --git a/API.md b/API.md new file mode 100644 index 00000000..fd5ff055 --- /dev/null +++ b/API.md @@ -0,0 +1,812 @@ +# Vet1177 API — Guide for frontend-utvecklare + +Denna guide visar hur du anropar backend-API:et från en React + Tailwind CSS-applikation. +Alla exempel är skrivna för en djurägare (OWNER) som heter Anna. + +--- + +## 1. Kom igang + +### Starta backend + +Backend startas i IntelliJ. Se till att Docker Desktop kor (behövs for databasen). +Kör `Vet1177Application.java` — servern startar pa `http://localhost:8080`. + +### Inloggning under utveckling + +Just nu anvander vi en speciell header istället for riktigt login. +Lagg till headern `X-Dev-User` med en e-postadress i varje request. +Det simulerar att du ar inloggad som den anvandaren. + +**OBS:** Detta fungerar bara nar backend kör med dev-profilen (vilket ar default lokalt). + +### Testanvandare + +| Roll | Namn | Email | +|-------|-----------------|-----------------| +| OWNER | Anna Svensson | anna@test.se | +| VET | Erik Veterinär | erik@klinik.se | +| ADMIN | Sara Admin | sara@admin.se | + +Alla testanvandare har lösenordet `password` (hashat i databasen). + +--- + +## 2. Hur man anropar API:et fran React + +### Grundmönster + +Alla anrop följer samma mönster: + +1. Anropa `fetch()` med rätt URL och headers +2. Kolla att `response.ok` ar `true` +3. Läs JSON-datan med `response.json()` + +```javascript +// Grundmönster for att hamta data +const hämtaData = async () => { + const response = await fetch('http://localhost:8080/api/ENDPOINT', { + headers: { + 'X-Dev-User': 'anna@test.se' // Talar om vem du ar + } + }); + + if (!response.ok) { + throw new Error('Något gick fel: ' + response.status); + } + + const data = await response.json(); + return data; +}; +``` + +```javascript +// Grundmönster for att skicka data (POST/PUT) +const skickaData = async (body) => { + const response = await fetch('http://localhost:8080/api/ENDPOINT', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', // Talar om att vi skickar JSON + 'X-Dev-User': 'anna@test.se' + }, + body: JSON.stringify(body) // Gör om JavaScript-objekt till JSON-text + }); + + if (!response.ok) { + throw new Error('Något gick fel: ' + response.status); + } + + const data = await response.json(); + return data; +}; +``` + +--- + +## 3. Endpoints for djurägare (OWNER) + +### 3.1 Hamta alla kliniker + +Anvands for att visa en dropdown nar djurägaren skapar ett nytt arende. +Alla kliniker ar publika — ingen header behövs. + +**`GET /api/clinics`** + +```javascript +const getKliniker = async () => { + const response = await fetch('http://localhost:8080/api/clinics'); + const data = await response.json(); + return data; +}; +``` + +**Svar (lista med kliniker):** + +```json +[ + { + "id": "a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5", + "name": "Djurkliniken Centrum", + "address": "Storgatan 1, Stockholm", + "phoneNumber": "08-123456" + } +] +``` + +--- + +### 3.2 Hamta mina djur + +Visar alla djur som tillhör den inloggade djurägaren. + +**`GET /pets/owner/{ownerId}`** + +```javascript +const getMinaDjur = async () => { + const ownerId = 'c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7'; // Annas ID + + const response = await fetch(`http://localhost:8080/pets/owner/${ownerId}`, { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; +``` + +**Svar (lista med djur):** + +```json +[ + { + "id": "f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0", + "ownerId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "name": "Fido", + "species": "Hund", + "breed": "Labrador", + "dateOfBirth": "2020-01-15", + "weightKg": 25.5, + "createdAt": "2025-01-01T10:00:00Z", + "updatedAt": "2025-01-01T10:00:00Z" + }, + { + "id": "a7b8c9d0-e1f2-4a5b-4c5d-e6f7a8b9c0d1", + "ownerId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "name": "Missan", + "species": "Katt", + "breed": "Persisk", + "dateOfBirth": "2019-06-20", + "weightKg": 4.2, + "createdAt": "2025-01-01T10:00:00Z", + "updatedAt": "2025-01-01T10:00:00Z" + } +] +``` + +--- + +### 3.3 Skapa ett djur + +Registrera ett nytt djur för den inloggade ägaren. + +**`POST /pets`** + +```javascript +const skapaDjur = async () => { + const response = await fetch('http://localhost:8080/pets', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Dev-User': 'anna@test.se' + }, + body: JSON.stringify({ + name: 'Bella', + species: 'Hund', + breed: 'Golden Retriever', + dateOfBirth: '2022-03-10', + weightKg: 30.0 + }) + }); + const data = await response.json(); + return data; +}; +``` + +**Request body:** + +| Fält | Typ | Obligatoriskt | Beskrivning | +|-------------|----------|---------------|---------------------------| +| name | string | Ja | Djurets namn | +| species | string | Ja | Art (Hund, Katt, etc.) | +| breed | string | Nej | Ras | +| dateOfBirth | string | Ja | Födelsedatum (YYYY-MM-DD) | +| weightKg | number | Ja | Vikt i kg (positivt tal) | + +**Svar:** Samma format som i listan ovan (ett PetResponse-objekt). + +--- + +### 3.4 Hamta mina ärenden + +Visar alla veterinärarenden som tillhör den inloggade djurägaren. + +**`GET /api/medical-records/my-records`** + +```javascript +const getMinaÄrenden = async () => { + const response = await fetch('http://localhost:8080/api/medical-records/my-records', { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; +``` + +**Svar (lista med ärenden i kortformat):** + +```json +[ + { + "id": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "title": "Fido haltar", + "status": "OPEN", + "petName": "Fido", + "ownerName": "Anna Svensson", + "assignedVetName": null, + "createdAt": "2025-01-01T10:00:00Z" + }, + { + "id": "c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3", + "title": "Missan äter inte", + "status": "IN_PROGRESS", + "petName": "Missan", + "ownerName": "Anna Svensson", + "assignedVetName": "Erik Veterinär", + "createdAt": "2025-01-01T10:00:00Z" + } +] +``` + +Möjliga statusar: `OPEN`, `IN_PROGRESS`, `AWAITING_INFO`, `CLOSED` + +--- + +### 3.5 Skapa ett ärende + +Skapar ett nytt veterinärarende. Du behöver ett `petId` (fran 3.2) och ett `clinicId` (fran 3.1). + +**`POST /api/medical-records`** + +```javascript +const skapaÄrende = async () => { + const response = await fetch('http://localhost:8080/api/medical-records', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Dev-User': 'anna@test.se' + }, + body: JSON.stringify({ + title: 'Fido hostar', + description: 'Hunden har hostat i tre dagar och verkar trött.', + petId: 'f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0', + clinicId: 'a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5' + }) + }); + const data = await response.json(); + return data; +}; +``` + +**Request body:** + +| Fält | Typ | Obligatoriskt | Beskrivning | +|-------------|--------|---------------|---------------------------------| +| title | string | Ja | Kort titel (max 500 tecken) | +| description | string | Nej | Beskrivning (max 5000 tecken) | +| petId | string | Ja | UUID for djuret | +| clinicId | string | Ja | UUID for kliniken | + +**Svar (fullstandigt arende):** + +```json +{ + "id": "nytt-uuid-här", + "title": "Fido hostar", + "description": "Hunden har hostat i tre dagar och verkar trött.", + "status": "OPEN", + "petId": "f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0", + "petName": "Fido", + "petSpecies": "Hund", + "ownerId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "ownerName": "Anna Svensson", + "clinicId": "a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5", + "clinicName": "Djurkliniken Centrum", + "assignedVetId": null, + "assignedVetName": null, + "createdById": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "createdByName": "Anna Svensson", + "createdAt": "2025-04-14T10:00:00Z", + "updatedAt": null, + "closedAt": null +} +``` + +--- + +### 3.6 Hamta ett specifikt arende + +Visar alla detaljer for ett arende. Anvands nar djurägaren klickar pa ett arende i listan. + +**`GET /api/medical-records/{id}`** + +```javascript +const getÄrende = async (ärendeId) => { + const response = await fetch(`http://localhost:8080/api/medical-records/${ärendeId}`, { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; + +// Anvandning: +const ärende = await getÄrende('b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2'); +``` + +**Svar:** Samma format som i 3.5 (MedicalRecordResponse). + +--- + +### 3.7 Skriva en kommentar + +Lagg till en kommentar pa ett arende. Tänk det som en chatt mellan djurägare och veterinär. + +**`POST /api/comments`** + +```javascript +const skapaKommentar = async (recordId, text) => { + const response = await fetch('http://localhost:8080/api/comments', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Dev-User': 'anna@test.se' + }, + body: JSON.stringify({ + recordId: recordId, + body: text + }) + }); + const data = await response.json(); + return data; +}; + +// Anvandning: +await skapaKommentar('b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2', 'Fido verkar bättre idag!'); +``` + +**Request body:** + +| Fält | Typ | Obligatoriskt | Beskrivning | +|----------|--------|---------------|----------------------------------| +| recordId | string | Ja | UUID for ärendet | +| body | string | Ja | Kommentarstexten (max 5000 tecken) | + +**Svar:** + +```json +{ + "id": "nytt-uuid-här", + "recordId": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "authorId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "authorName": "Anna Svensson", + "body": "Fido verkar bättre idag!", + "createdAt": "2025-04-14T10:00:00Z", + "updatedAt": null +} +``` + +--- + +### 3.8 Hamta kommentarer for ett arende + +Visar alla kommentarer pa ett arende, sorterade fran äldst till nyast. + +**`GET /api/comments/record/{recordId}`** + +```javascript +const getKommentarer = async (recordId) => { + const response = await fetch(`http://localhost:8080/api/comments/record/${recordId}`, { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; + +// Anvandning: +const kommentarer = await getKommentarer('b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2'); +``` + +**Svar (lista med kommentarer):** + +```json +[ + { + "id": "kommentar-uuid", + "recordId": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "authorId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "authorName": "Anna Svensson", + "body": "Fido verkar bättre idag!", + "createdAt": "2025-04-14T10:00:00Z", + "updatedAt": null + } +] +``` + +--- + +### 3.9 Bifoga en fil + +Ladda upp en bild eller PDF till ett arende. Bilagor skickas som `multipart/form-data` — inte JSON. +Det ar samma format som ett vanligt HTML-formulär med ``. + +**`POST /api/attachments/record/{recordId}`** + +```javascript +const laddaUppFil = async (recordId, file, beskrivning) => { + // FormData anvands istället for JSON nar man skickar filer + const formData = new FormData(); + formData.append('file', file); // file = File-objekt fran + formData.append('description', beskrivning); // Valfri beskrivning + + const response = await fetch(`http://localhost:8080/api/attachments/record/${recordId}`, { + method: 'POST', + headers: { + 'X-Dev-User': 'anna@test.se' + // OBS: Satt INTE 'Content-Type' här — browsern lägger till det automatiskt + // med rätt boundary for multipart/form-data + }, + body: formData // Skicka FormData direkt, INTE JSON.stringify() + }); + const data = await response.json(); + return data; +}; + +// Anvandning i en React-komponent: +// laddaUppFil(recordId, e.target.files[0], 'Rontgenbild')} /> +``` + +**Tillåtna filtyper:** JPG, PNG, PDF +**Maxstorlek:** 10 MB + +**Svar (201 Created):** + +```json +{ + "id": "bilaga-uuid", + "recordId": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "fileName": "rontgen_fido.jpg", + "description": "Rontgenbild", + "fileType": "image/jpeg", + "fileSizeBytes": 245000, + "uploadedAt": "2025-04-14T10:00:00Z", + "uploadedBy": "Anna Svensson", + "downloadUrl": "https://minio.../presigned-url" +} +``` + +--- + +### 3.10 Hamta bilagor for ett arende + +Visar alla uppladdade filer pa ett arende. Varje bilaga har en `downloadUrl` som du kan anvanda som `src` i en `` eller som `href` i en ``. + +**`GET /api/attachments/record/{recordId}`** + +```javascript +const getBilagor = async (recordId) => { + const response = await fetch(`http://localhost:8080/api/attachments/record/${recordId}`, { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; +``` + +**Svar (lista med bilagor):** + +```json +[ + { + "id": "bilaga-uuid", + "recordId": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "fileName": "rontgen_fido.jpg", + "description": "Rontgenbild", + "fileType": "image/jpeg", + "fileSizeBytes": 245000, + "uploadedAt": "2025-04-14T10:00:00Z", + "uploadedBy": "Anna Svensson", + "downloadUrl": "https://minio.../presigned-url" + } +] +``` + +**Tips:** `downloadUrl` ar en tidsbegränsad länk (giltig i 15 minuter). Om bilden inte laddas, hamta bilagorna igen for att fa en ny URL. + +--- + +## 4. Statuskoder att känna till + +Nar du anropar API:et far du alltid tillbaka en HTTP-statuskod. Kolla den i `response.status`. + +| Kod | Namn | Vad det betyder | +|-----|------------------|--------------------------------------------------------------| +| 200 | OK | Allt fungerade. Datan finns i response body. | +| 201 | Created | Resursen skapades. Datan finns i response body. | +| 204 | No Content | Lyckades, men inget data i svaret (t.ex. vid delete). | +| 400 | Bad Request | Något ar fel i din request. Kolla att alla fält ar ifyllda. | +| 403 | Forbidden | Du har inte rättighet. T.ex. en OWNER som försöker nå en VET-endpoint. | +| 404 | Not Found | Resursen finns inte. Kolla att UUID:t stämmer. | +| 500 | Server Error | Något gick fel i backend. Kolla IntelliJ-loggen. | + +**Felmeddelanden** kommer som JSON: + +```json +{ + "status": 400, + "message": "Validation failed", + "errors": { + "title": ["Titel far inte vara tom"] + } +} +``` + +--- + +## 5. Nar JWT ar implementerat + +Just nu anvander vi `X-Dev-User`-headern for att simulera inloggning. Nar JWT ar klart byter du ut den mot en riktig token. + +### Före (nu — utvecklingsläge) + +```javascript +headers: { + 'X-Dev-User': 'anna@test.se' +} +``` + +### Efter (med JWT) + +```javascript +// 1. Logga in och fa en token +const login = async (email, password) => { + const response = await fetch('http://localhost:8080/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + const data = await response.json(); + return data.token; // Spara denna i state eller localStorage +}; + +// 2. Anvand token i alla andra anrop +headers: { + 'Authorization': `Bearer ${token}` +} +``` + +**Allt annat ar likadant** — samma URLer, samma request bodies, samma svar. Bara headern andras. + +--- + +## 6. Kända test-UUID:n + +Dessa UUID:n finns i testdatan som laddas vid uppstart. Anvand dem for att testa utan att behöva skapa data först. + +### Klinik + +| Namn | UUID | +|-----------------------|----------------------------------------| +| Djurkliniken Centrum | `a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5` | + +### Anvandare + +| Namn | Roll | Email | UUID | +|-----------------|-------|----------------|----------------------------------------| +| Anna Svensson | OWNER | anna@test.se | `c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7` | +| Erik Veterinär | VET | erik@klinik.se | `d4e5f6a7-b8c9-4d5e-1f2a-b3c4d5e6f7a8` | +| Sara Admin | ADMIN | sara@admin.se | `e5f6a7b8-c9d0-4e5f-2a3b-c4d5e6f7a8b9` | + +### Djur + +| Namn | Art | Ras | UUID | +|--------|------|-----------|----------------------------------------| +| Fido | Hund | Labrador | `f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0` | +| Missan | Katt | Persisk | `a7b8c9d0-e1f2-4a5b-4c5d-e6f7a8b9c0d1` | + +### Arenden + +| Titel | Status | UUID | +|-----------------|-------------|----------------------------------------| +| Fido haltar | OPEN | `b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2` | +| Missan äter inte | IN_PROGRESS | `c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3` | + +--- + +## 7. Alla endpoints — komplett lista + +Nedan ar samtliga endpoints i API:et, grupperade per controller. +Kolumnen "Auth" visar vem som kan anropa endpointen. + +**Förkortningar:** +- **Alla** = ingen inloggning krävs (permitAll) +- **Inloggad** = alla inloggade anvandare +- **OWNER** = djurägare +- **VET** = veterinär +- **ADMIN** = administratör +- **Policy** = rollkontroll sker i service/policy-lagret, inte i controllern + +### Autentisering (`/api/auth`) + +*Dessa endpoints finns ännu inte — de skapas av Person B (se issue #1–3).* + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/api/auth/register` | Alla | Registrera ny anvandare | `{ name, email, password, role }` | +| POST | `/api/auth/login` | Alla | Logga in, fa JWT-token | `{ email, password }` | + +### Kliniker (`/api/clinics`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| GET | `/api/clinics` | Alla | Hamta alla kliniker | — | +| GET | `/api/clinics/{id}` | Alla | Hamta en klinik | — | +| POST | `/api/clinics` | Inloggad | Skapa klinik | `{ name, address, phoneNumber }` | +| PUT | `/api/clinics/{id}` | Inloggad | Uppdatera klinik | `{ name, address, phoneNumber }` | +| DELETE | `/api/clinics/{id}` | Inloggad | Ta bort klinik | — | + +**Svar (ClinicResponse):** +```json +{ "id": "uuid", "name": "...", "address": "...", "phoneNumber": "..." } +``` + +### Djur (`/pets`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/pets` | OWNER/ADMIN (Policy) | Skapa djur. Admin kan ange `?ownerId=uuid` | `{ name, species, breed, dateOfBirth, weightKg }` | +| GET | `/pets/{petId}` | OWNER/VET/ADMIN (Policy) | Hamta ett djur | — | +| GET | `/pets/owner/{ownerId}` | OWNER/ADMIN (Policy) | Hamta alla djur for en ägare | — | +| PUT | `/pets/{petId}` | OWNER/ADMIN (Policy) | Uppdatera djurinfo | `{ name, species, breed, dateOfBirth, weightKg }` | +| DELETE | `/pets/{petId}` | OWNER/ADMIN (Policy) | Ta bort djur | — | + +**Svar (PetResponse):** +```json +{ + "id": "uuid", "ownerId": "uuid", "name": "Fido", "species": "Hund", + "breed": "Labrador", "dateOfBirth": "2020-01-15", "weightKg": 25.5, + "createdAt": "2025-01-01T10:00:00Z", "updatedAt": "2025-01-01T10:00:00Z" +} +``` + +### Arenden (`/api/medical-records`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/api/medical-records` | Inloggad (Policy) | Skapa arende | `{ title, description, petId, clinicId }` | +| GET | `/api/medical-records/{id}` | Inloggad (Policy) | Hamta ett arende (fullstandig) | — | +| GET | `/api/medical-records/my-records` | OWNER | Hamta mina ärenden (kortformat) | — | +| GET | `/api/medical-records/owner/{ownerId}` | OWNER (egna)/VET/ADMIN | Hamta ärenden per ägare | — | +| GET | `/api/medical-records/pet/{petId}` | Inloggad (Policy) | Hamta ärenden per djur | — | +| GET | `/api/medical-records/clinic/{clinicId}` | VET/ADMIN (Policy) | Hamta ärenden per klinik | — | +| GET | `/api/medical-records/clinic/{clinicId}/status/{status}` | VET/ADMIN (Policy) | Hamta ärenden per klinik + status | — | +| PUT | `/api/medical-records/{id}` | VET/ADMIN (Policy) | Uppdatera titel/beskrivning | `{ title, description }` | +| PUT | `/api/medical-records/{id}/assign-vet` | VET/ADMIN (Policy) | Tilldela veterinär | `{ vetId }` | +| PUT | `/api/medical-records/{id}/status` | VET/ADMIN (Policy) | Ändra status | `{ status }` | +| PUT | `/api/medical-records/{id}/close` | VET/ADMIN (Policy) | Stäng ärende | — | + +**Möjliga statusar:** `OPEN`, `IN_PROGRESS`, `AWAITING_INFO`, `CLOSED` + +**Svar — fullständigt (MedicalRecordResponse):** +```json +{ + "id": "uuid", "title": "...", "description": "...", "status": "OPEN", + "petId": "uuid", "petName": "Fido", "petSpecies": "Hund", + "ownerId": "uuid", "ownerName": "Anna Svensson", + "clinicId": "uuid", "clinicName": "Djurkliniken Centrum", + "assignedVetId": null, "assignedVetName": null, + "createdById": "uuid", "createdByName": "Anna Svensson", + "createdAt": "2025-01-01T10:00:00Z", "updatedAt": null, "closedAt": null +} +``` + +**Svar — kortformat (MedicalRecordSummaryResponse):** +```json +{ + "id": "uuid", "title": "...", "status": "OPEN", + "petName": "Fido", "ownerName": "Anna Svensson", + "assignedVetName": null, "createdAt": "2025-01-01T10:00:00Z" +} +``` + +### Kommentarer (`/api/comments`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/api/comments` | Inloggad (Policy) | Skapa kommentar | `{ recordId, body }` | +| GET | `/api/comments/record/{recordId}` | Inloggad (Policy) | Hamta kommentarer for ett arende | — | +| GET | `/api/comments/record/{recordId}/count` | Inloggad (Policy) | Antal kommentarer pa ett arende | — | +| PUT | `/api/comments/{id}` | Inloggad (Policy) | Uppdatera kommentar | `{ body }` | +| DELETE | `/api/comments/{id}` | Inloggad (Policy) | Ta bort kommentar | — | + +**Svar (CommentResponse):** +```json +{ + "id": "uuid", "recordId": "uuid", "authorId": "uuid", + "authorName": "Anna Svensson", "body": "Fido verkar bättre!", + "createdAt": "2025-04-14T10:00:00Z", "updatedAt": null +} +``` + +### Bilagor (`/api/attachments`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/api/attachments/record/{recordId}` | Inloggad (Policy) | Ladda upp fil (multipart/form-data) | `file` + valfri `description` | +| GET | `/api/attachments/record/{recordId}` | Inloggad (Policy) | Hamta bilagor for ett arende | — | +| GET | `/api/attachments/{id}/download` | Inloggad (Policy) | Hamta en bilaga (presigned URL) | — | +| DELETE | `/api/attachments/{id}` | VET/ADMIN (Policy) | Ta bort bilaga | — | + +**Tillåtna filtyper:** JPG, PNG, PDF. **Max:** 10 MB. + +**Svar (AttachmentResponse):** +```json +{ + "id": "uuid", "recordId": "uuid", "fileName": "bild.jpg", + "description": "Rontgenbild", "fileType": "image/jpeg", + "fileSizeBytes": 245000, "uploadedAt": "2025-04-14T10:00:00Z", + "uploadedBy": "Anna Svensson", "downloadUrl": "https://presigned-url..." +} +``` + +### Aktivitetslogg (`/api/activity-logs`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| GET | `/api/activity-logs/record/{recordId}` | Inloggad (Policy) | Hamta händelselogg for ett arende | — | + +**Svar (lista med ActivityLogResponse):** +```json +[ + { + "id": "uuid", "action": "CASE_CREATED", "description": "Ärende skapat", + "performedById": "uuid", "performedByName": "Anna Svensson", + "recordId": "uuid", "createdAt": "2025-01-01T10:00:00Z" + } +] +``` + +**Möjliga actions:** `CASE_CREATED`, `UPDATED`, `STATUS_CHANGED`, `ASSIGNED`, `COMMENT_ADDED` + +### Anvandare (`/api/users`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| GET | `/api/users` | Inloggad | Hamta alla anvandare | — | +| GET | `/api/users/{id}` | Inloggad | Hamta en anvandare | — | +| POST | `/api/users` | Inloggad | Skapa anvandare | `{ name, email, password, role, clinicId? }` | +| PUT | `/api/users/{id}` | Inloggad | Uppdatera anvandare | `{ name?, email?, clinicId? }` | +| DELETE | `/api/users/{id}` | Inloggad | Ta bort anvandare | — | + +**Svar (UserResponse):** +```json +{ + "id": "uuid", "name": "Anna Svensson", "email": "anna@test.se", + "role": "OWNER", "clinicId": null, + "createdAt": "2025-01-01T10:00:00Z", "updatedAt": "2025-01-01T10:00:00Z" +} +``` + +### Veterinärer (`/api/vets`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| GET | `/api/vets` | Inloggad | Hamta alla veterinärer | — | +| GET | `/api/vets/{id}` | Inloggad | Hamta en veterinär | — | +| POST | `/api/vets` | ADMIN | Skapa veterinärprofil | `{ userId, licenseId, specialization?, bookingInfo? }` | + +**Svar (VetResponse):** +```json +{ + "userId": "uuid", "name": "Erik Veterinär", "email": "erik@klinik.se", + "licenseId": "VET-001", "specialization": "Ortopedi", + "bookingInfo": "Mån-Fre 08-17", "clinicName": "Djurkliniken Centrum", + "isActive": true +} diff --git a/pom.xml b/pom.xml index 92752b0e..6ec7ad1e 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + software.amazon.awssdk s3 diff --git a/src/main/java/org/example/vet1177/controller/ActivityLogController.java b/src/main/java/org/example/vet1177/controller/ActivityLogController.java index 5086b488..898dc1b8 100644 --- a/src/main/java/org/example/vet1177/controller/ActivityLogController.java +++ b/src/main/java/org/example/vet1177/controller/ActivityLogController.java @@ -3,9 +3,9 @@ import org.example.vet1177.dto.response.activitylog.ActivityLogResponse; import org.example.vet1177.entities.User; import org.example.vet1177.services.ActivityLogService; -import org.example.vet1177.services.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -18,26 +18,22 @@ public class ActivityLogController { private static final Logger log = LoggerFactory.getLogger(ActivityLogController.class); private final ActivityLogService activityLogService; - private final UserService userService; - public ActivityLogController(ActivityLogService activityLogService, - UserService userService) { + public ActivityLogController(ActivityLogService activityLogService) { this.activityLogService = activityLogService; - this.userService = userService; } // Hämta alla logs för ett MedicalRecord @GetMapping("/record/{recordId}") public List getLogsByRecord( @PathVariable UUID recordId, - @RequestHeader("userId") UUID userId + @AuthenticationPrincipal User currentUser ) { log.info("GET /api/activity-logs/record/{}", recordId); - User user = userService.getUserEntityById(userId); - return activityLogService.getByRecord(recordId, user) + return activityLogService.getByRecord(recordId, currentUser) .stream() .map(ActivityLogResponse::from) .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/example/vet1177/controller/PetController.java b/src/main/java/org/example/vet1177/controller/PetController.java index 0c808159..99677543 100644 --- a/src/main/java/org/example/vet1177/controller/PetController.java +++ b/src/main/java/org/example/vet1177/controller/PetController.java @@ -8,8 +8,8 @@ import org.example.vet1177.services.PetService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.example.vet1177.services.UserService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -22,44 +22,42 @@ public class PetController { private static final Logger log = LoggerFactory.getLogger(PetController.class); private final PetService petService; - private final UserService userService; - public PetController(PetService petService, UserService userService) { + public PetController(PetService petService) { this.petService = petService; - this.userService = userService; } - //POST / pets - skapa nytt djur + //POST /pets - skapa nytt djur @PostMapping public PetResponse createPet( - // TODO: Ersätt med användare från autentiserad kontext (t.ex. JWT / Spring Security) - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @RequestParam(required = false) UUID ownerId, @Valid @RequestBody PetRequest request ) { - log.info("POST /pets - creating pet for currentUserId={} ownerId={}", currentUserId, ownerId); - Pet saved = petService.createPet(currentUserId, ownerId, request); + log.info("POST /pets - creating pet for userId={} ownerId={}", currentUser.getId(), ownerId); + Pet saved = petService.createPet(currentUser.getId(), ownerId, request); return toResponse(saved); } - // GET / pets/{petId} - hämta ett specifikt djur + // GET /pets/{petId} - hämta ett specifikt djur @GetMapping("/{petId}") public ResponseEntity getPetById( - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @PathVariable UUID petId ) { - User currentUser = userService.getUserEntityById(currentUserId); + log.info("GET /pets/{}", petId); Pet pet = petService.getPetById(petId, currentUser); return ResponseEntity.ok(toResponse(pet)); } - // GET/pets/owner/{ownerId} - hämta alla djur för en ägare + // GET /pets/owner/{ownerId} - hämta alla djur för en ägare @GetMapping("/owner/{ownerId}") public ResponseEntity> getPetsByOwner( - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @PathVariable UUID ownerId ) { - List pets = petService.getPetsByOwner(currentUserId, ownerId) + log.info("GET /pets/owner/{}", ownerId); + List pets = petService.getPetsByOwner(currentUser.getId(), ownerId) .stream() .map(this::toResponse) .toList(); @@ -69,21 +67,22 @@ public ResponseEntity> getPetsByOwner( // PUT /pets/{petId} - uppdatera ett djur @PutMapping("/{petId}") public ResponseEntity updatePet( - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @PathVariable UUID petId, @Valid @RequestBody PetRequest request ) { - Pet updated = petService.updatePet(currentUserId, petId, request); + log.info("PUT /pets/{}", petId); + Pet updated = petService.updatePet(currentUser.getId(), petId, request); return ResponseEntity.ok(toResponse(updated)); } // DELETE /pets/{petId} - radera ett djur @DeleteMapping("/{petId}") public ResponseEntity deletePet( - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @PathVariable UUID petId ) { - User currentUser = userService.getUserEntityById(currentUserId); + log.info("DELETE /pets/{}", petId); petService.deletePet(petId, currentUser); return ResponseEntity.noContent().build(); } @@ -103,4 +102,4 @@ private PetResponse toResponse(Pet pet) { ); } -} \ No newline at end of file +} diff --git a/src/main/java/org/example/vet1177/security/CustomUserDetailsService.java b/src/main/java/org/example/vet1177/security/CustomUserDetailsService.java new file mode 100644 index 00000000..3251b4ed --- /dev/null +++ b/src/main/java/org/example/vet1177/security/CustomUserDetailsService.java @@ -0,0 +1,50 @@ +package org.example.vet1177.security; + +import org.example.vet1177.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * Bryggan mellan Spring Security och vår databas. + * + * Spring Security behöver veta hur man hämtar en användare givet ett "användarnamn". + * I vårt fall är "användarnamnet" email-adressen (det som står i JWT:ns subject-claim). + * + * Flödet: + * JWT-token innehåller sub="sara@vet.se" + * ↓ + * JwtAuthenticationFilter anropar loadUserByUsername("sara@vet.se") + * ↓ + * Vi söker i databasen: userRepository.findByEmail("sara@vet.se") + * ↓ + * Returnerar User-objektet (som implementerar UserDetails) + * ↓ + * Spring Security sätter det i SecurityContext → @AuthenticationPrincipal fungerar + */ +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private static final Logger log = LoggerFactory.getLogger(CustomUserDetailsService.class); + + private final UserRepository userRepository; + + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + log.debug("Loading user by email={}", email); + + + return userRepository.findByEmail(email) + .orElseThrow(() -> { + log.warn("User not found email={}", email); + return new UsernameNotFoundException("Användare hittades inte: " + email); + }); + } +} diff --git a/src/main/java/org/example/vet1177/security/JwtAuthenticationFilter.java b/src/main/java/org/example/vet1177/security/JwtAuthenticationFilter.java new file mode 100644 index 00000000..519eb079 --- /dev/null +++ b/src/main/java/org/example/vet1177/security/JwtAuthenticationFilter.java @@ -0,0 +1,128 @@ +package org.example.vet1177.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Filter som körs EN gång per HTTP-request (OncePerRequestFilter). + * + * Vad händer steg för steg: + * + * [Klient] → HTTP Request med "Authorization: Bearer eyJhbG..." + * ↓ + * [JwtAuthenticationFilter] + * 1. Läser Authorization-headern + * 2. Extraherar token-strängen (allt efter "Bearer ") + * 3. Avkodar token via JwtService → får email + roll + * 4. Laddar User från DB via CustomUserDetailsService + * 5. Skapar ett Authentication-objekt och sätter det i SecurityContext + * ↓ + * [Spring Security] + * Kollar SecurityContext: finns en autentiserad användare? + * Om ja → kontrollerar att användaren har rätt roll för endpointen + * Om nej → returnerar 401 Unauthorized + * ↓ + * [Controller] + * @AuthenticationPrincipal User currentUser ← hämtas från SecurityContext + */ +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class); + + private final JwtService jwtService; + private final CustomUserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtService jwtService, CustomUserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // 1. Läs Authorization-headern + // Formatet är: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + String authHeader = request.getHeader("Authorization"); + + // Om headern saknas eller inte börjar med "Bearer " → skippa filtret. + // Requesten släpps vidare i kedjan. Om endpointen kräver auth + // kommer Spring Security att returnera 401 automatiskt. + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + // 2. Extrahera token-strängen (ta bort "Bearer " som är 7 tecken) + String token = authHeader.substring(7); + + try { + // 3. Avkoda och validera token. + // Om signaturen inte stämmer eller token har gått ut kastar decode() JwtException. + Jwt jwt = jwtService.decode(token); + + // 4. Hämta email från token (subject-claim) + String email = jwt.getSubject(); + + // Kolla att vi inte redan har autentiserat denna request + // (kan hända om flera filter kör i kedjan) + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + + // 5. Ladda det fulla User-objektet från databasen. + // Vi behöver hela entiteten (med id, klinik, roll) för @AuthenticationPrincipal. + var userDetails = userDetailsService.loadUserByUsername(email); + + // 6. Skapa ett Authentication-objekt. + // UsernamePasswordAuthenticationToken är Springs standardklass för "en autentiserad användare". + // - Första argumentet (principal) → User-objektet (det som @AuthenticationPrincipal ger) + // - Andra argumentet (credentials) → null (vi behöver inte lösenordet, token räcker) + // - Tredje argumentet (authorities) → rollerna (ROLE_VET, ROLE_ADMIN, etc.) + var authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + + // Kopplar request-detaljer (IP-adress, session-id) till autentiseringen — för loggning. + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + // 7. Sätt autentiseringen i SecurityContext. + // Från och med nu "vet" Spring Security att det finns en inloggad användare. + // Alla controllers kan hämta den via @AuthenticationPrincipal. + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("Authenticated user email={} via JWT", email); + } + + } catch (JwtException e) { + // Token är ogiltig (felaktig signatur, utgången, korrupt). + // Vi loggar och släpper vidare — Spring Security returnerar 401 automatiskt + // eftersom SecurityContext förblir tom. + log.warn("Invalid JWT token: {}", e.getMessage()); + } catch (UsernameNotFoundException e) { + // Token är giltig men användaren finns inte längre i databasen + // (t.ex. raderad av admin medan tokenen fortfarande gäller). + log.warn("User from JWT no longer exists: {}", e.getMessage()); + } + + // Släpp vidare requesten till nästa filter i kedjan (och sedan till controllern) + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/org/example/vet1177/security/JwtProperties.java b/src/main/java/org/example/vet1177/security/JwtProperties.java new file mode 100644 index 00000000..1c165f55 --- /dev/null +++ b/src/main/java/org/example/vet1177/security/JwtProperties.java @@ -0,0 +1,22 @@ +package org.example.vet1177.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Läser JWT-konfiguration från application.properties. + * + * jwt.secret-key → den hemliga nyckeln som används för att signera/verifiera tokens (HMAC SHA-256) + * jwt.expiration-ms → hur länge en token är giltig i millisekunder (86400000 = 24 timmar) + */ +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + + private String secretKey; + private long expirationMs; + + public String getSecretKey() { return secretKey; } + public void setSecretKey(String secretKey) { this.secretKey = secretKey; } + + public long getExpirationMs() { return expirationMs; } + public void setExpirationMs(long expirationMs) { this.expirationMs = expirationMs; } +} diff --git a/src/main/java/org/example/vet1177/security/JwtService.java b/src/main/java/org/example/vet1177/security/JwtService.java new file mode 100644 index 00000000..6643c970 --- /dev/null +++ b/src/main/java/org/example/vet1177/security/JwtService.java @@ -0,0 +1,115 @@ +package org.example.vet1177.security; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import org.example.vet1177.entities.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.*; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +/** + * Hanterar skapande och validering av JWT-tokens. + * + * Flödet: + * 1. Användaren loggar in med email + lösenord + * 2. AuthService anropar generateToken(user) → får tillbaka en JWT-sträng + * 3. Frontend skickar JWT:n i varje request: "Authorization: Bearer " + * 4. JwtAuthenticationFilter fångar headern, anropar decode(token) → får tillbaka claims + * 5. Filtret laddar användaren från DB och sätter den i SecurityContext + * + * Vi använder HS256 (HMAC med SHA-256) — en symmetrisk algoritm där samma + * hemliga nyckel används för att både signera och verifiera tokens. + */ +@Service +public class JwtService { + + private static final Logger log = LoggerFactory.getLogger(JwtService.class); + + private final JwtEncoder jwtEncoder; + private final JwtDecoder jwtDecoder; + private final long expirationMs; + + public JwtService(JwtProperties jwtProperties) { + byte[] keyBytes = jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8); + if (keyBytes.length < 32) { + throw new IllegalStateException( + "JWT secret key must be at least 32 bytes for HS256, got " + keyBytes.length); + } + + SecretKey key = new SecretKeySpec(keyBytes, "HmacSHA256"); + + // JwtEncoder — skapar nya tokens. + // ImmutableSecret wrapprar vår nyckel så att Nimbus-biblioteket kan använda den. + this.jwtEncoder = new NimbusJwtEncoder(new ImmutableSecret<>(key)); + + // JwtDecoder — avkodar och validerar befintliga tokens. + // Den kontrollerar automatiskt: signatur (är tokenen äkta?) och utgångstid (har den gått ut?). + this.jwtDecoder = NimbusJwtDecoder.withSecretKey(key) + .macAlgorithm(MacAlgorithm.HS256) + .build(); + + this.expirationMs = jwtProperties.getExpirationMs(); + } + + /** + * Skapar en ny JWT för en inloggad användare. + * + * Tokenen innehåller "claims" — nyckel-värde-par med information: + * - sub (subject): användarens email — det unika identifieringsvärdet + * - userId: UUID:t — behövs för att hämta användaren från DB + * - role: ROLE_VET / ROLE_OWNER / ROLE_ADMIN — för rollbaserad åtkomstkontroll + * - iat (issued at): när tokenen skapades + * - exp (expires at): när tokenen slutar gälla + */ + public String generateToken(User user) { + Instant now = Instant.now(); + + JwtClaimsSet claims = JwtClaimsSet.builder() + .subject(user.getEmail()) + .claim("userId", user.getId().toString()) + .claim("role", "ROLE_" + user.getRole().name()) + .issuedAt(now) + .expiresAt(now.plusMillis(expirationMs)) + .build(); + + // Talar om att vi vill signera med HS256-algoritmen + JwtEncoderParameters params = JwtEncoderParameters.from( + JwsHeader.with(MacAlgorithm.HS256).build(), + claims + ); + + String token = jwtEncoder.encode(params).getTokenValue(); + log.info("Generated JWT for user email={}", user.getEmail()); + return token; + } + + /** + * Avkodar och validerar en JWT-sträng. + * + * Nimbus-biblioteket gör automatiskt: + * 1. Kontrollerar att signaturen stämmer (ingen har ändrat innehållet) + * 2. Kontrollerar att tokenen inte har gått ut (exp > nu) + * + * Om något är fel kastas JwtException och anroparen vet att tokenen är ogiltig. + * + * @return Jwt-objekt med alla claims om tokenen är giltig + * @throws JwtException om tokenen är ogiltig, manipulerad eller utgången + */ + public Jwt decode(String token) { + return jwtDecoder.decode(token); + } + + /** + * Exponerar JwtDecoder som en bean som Spring Security kan använda. + * SecurityConfig behöver en JwtDecoder-bean för att konfigurera oauth2ResourceServer(). + */ + public JwtDecoder getJwtDecoder() { + return jwtDecoder; + } +} diff --git a/src/main/java/org/example/vet1177/security/SecurityConfig.java b/src/main/java/org/example/vet1177/security/SecurityConfig.java index 3f867e90..377048b2 100644 --- a/src/main/java/org/example/vet1177/security/SecurityConfig.java +++ b/src/main/java/org/example/vet1177/security/SecurityConfig.java @@ -1,28 +1,149 @@ package org.example.vet1177.security; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.List; + +/** + * Hela säkerhetskonfigurationen för applikationen. + * + * @EnableConfigurationProperties(JwtProperties.class) — talar om för Spring att + * läsa jwt.secret-key och jwt.expiration-ms från application.properties och + * skapa en JwtProperties-bean som kan injiceras i JwtService. + */ @Configuration +@EnableConfigurationProperties(JwtProperties.class) public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomUserDetailsService userDetailsService; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, + CustomUserDetailsService userDetailsService) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.userDetailsService = userDetailsService; + } + + /** + * SecurityFilterChain — definierar hela säkerhetskedjan. + * + * Varje rad i kedjan lägger till en regel: + */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) - throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http + // CORS — tillåter frontend (annan port/domän) att anropa vårt API. + // Utan detta blockerar webbläsaren alla cross-origin requests. + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + + // CSRF — avstängt. CSRF-skydd behövs för cookie-baserad auth + // men är irrelevant för JWT (token skickas i header, inte cookie). .csrf(csrf -> csrf.disable()) + + // Sessionhantering — STATELESS. + // Servern sparar ingen session. All info finns i JWT-tokenen. + // Varje request är oberoende — filtret avkodar token varje gång. + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // Endpoint-regler — vilka URLs kräver vad. + // Ordningen spelar roll: första matchande regel vinner. .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() // ← temporärt, öppnar allt + // Öppna endpoints — ingen token krävs + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/clinics", "/api/clinics/**").permitAll() + + // Alla andra API-anrop kräver att man är inloggad + .anyRequest().authenticated() ) + + // Registrera vårt JWT-filter FÖRE Springs inbyggda UsernamePasswordAuthenticationFilter. + // Det betyder att JWT-filtret körs först och sätter SecurityContext + // innan Spring kollar om användaren har rätt behörighet. + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); } + /** + * JwtDecoder-bean — Spring Security använder denna för att verifiera JWT-tokens. + * Vi hämtar den från JwtService som redan har konfigurerat den med vår hemliga nyckel. + */ + @Bean + public JwtDecoder jwtDecoder(JwtService jwtService) { + return jwtService.getJwtDecoder(); + } + + /** + * PasswordEncoder — BCrypt är industristandard. + * Används vid registrering (hasha lösenord) och login (verifiera lösenord). + * BCrypt saltar automatiskt — samma lösenord ger olika hash varje gång. + */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} \ No newline at end of file + + /** + * AuthenticationManager — hanterar login-flödet. + * + * När Person B:s AuthController anropar authManager.authenticate(email, password): + * 1. AuthenticationManager delegerar till DaoAuthenticationProvider + * 2. DaoAuthenticationProvider anropar CustomUserDetailsService.loadUserByUsername(email) + * 3. DaoAuthenticationProvider jämför lösenord med PasswordEncoder.matches() + * 4. Om match → returnerar Authentication med User-objektet + * 5. Om mismatch → kastar BadCredentialsException + */ + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + /** + * DaoAuthenticationProvider — kopplar ihop UserDetailsService med PasswordEncoder. + * "Dao" = Data Access Object — den hämtar data från vår databas. + */ + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + /** + * CORS-konfiguration — vilka origins (domäner) som får anropa vårt API. + * + * I utveckling: localhost:5173 (Vite/React), localhost:3000 (Next.js) + * I produktion: byt till er riktiga frontend-URL. + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:3000")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("Authorization", "Content-Type")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + return source; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 87967623..b926dda2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -27,4 +27,8 @@ aws.s3.bucket-name=${S3_BUCKET:} aws.s3.region=${S3_REGION:} spring.servlet.multipart.max-file-size=10MB -spring.servlet.multipart.max-request-size=10MB \ No newline at end of file +spring.servlet.multipart.max-request-size=10MB + +# JWT Configuration +jwt.secret-key=${JWT_SECRET:default-dev-secret-key-change-in-production-min-32-chars!!} +jwt.expiration-ms=86400000 \ No newline at end of file diff --git a/src/test/java/org/example/vet1177/controller/ActivityLogControllerTest.java b/src/test/java/org/example/vet1177/controller/ActivityLogControllerTest.java index 00b584a3..aa92f78b 100644 --- a/src/test/java/org/example/vet1177/controller/ActivityLogControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/ActivityLogControllerTest.java @@ -1,15 +1,15 @@ package org.example.vet1177.controller; import org.example.vet1177.entities.*; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; +import org.example.vet1177.security.SecurityConfig; import org.example.vet1177.services.ActivityLogService; -import org.example.vet1177.services.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; - -// ✅ RÄTT (Boot 4) import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; - +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -18,11 +18,12 @@ import java.util.UUID; import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ActivityLogController.class) -@AutoConfigureMockMvc(addFilters = false) // 🔥 stänger av security (fixar 401) +@Import(SecurityConfig.class) class ActivityLogControllerTest { @Autowired @@ -32,7 +33,10 @@ class ActivityLogControllerTest { private ActivityLogService activityLogService; @MockitoBean - private UserService userService; + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; @Test void shouldReturnLogsForRecord() throws Exception { @@ -47,6 +51,7 @@ void shouldReturnLogsForRecord() throws Exception { User user = mock(User.class); when(user.getId()).thenReturn(userId); when(user.getName()).thenReturn("Alice"); + when(user.getAuthorities()).thenReturn(List.of()); // Mock MedicalRecord MedicalRecord record = mock(MedicalRecord.class); @@ -61,17 +66,18 @@ void shouldReturnLogsForRecord() throws Exception { when(log.getMedicalRecord()).thenReturn(record); when(log.getCreatedAt()).thenReturn(createdAt); - // Mock service responses - when(userService.getUserEntityById(userId)).thenReturn(user); + // Mock service when(activityLogService.getByRecord(recordId, user)) .thenReturn(List.of(log)); // Act + Assert mockMvc.perform(get("/api/activity-logs/record/" + recordId) - .header("userId", userId.toString())) + .with(authentication(new UsernamePasswordAuthenticationToken( + user, null, user.getAuthorities() + )))) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].description").value("Created case")) .andExpect(jsonPath("$[0].action").value("CASE_CREATED")) .andExpect(jsonPath("$[0].performedByName").value("Alice")); } -} \ No newline at end of file +} diff --git a/src/test/java/org/example/vet1177/controller/AttachmentControllerTest.java b/src/test/java/org/example/vet1177/controller/AttachmentControllerTest.java index 01467030..9fb12fa5 100644 --- a/src/test/java/org/example/vet1177/controller/AttachmentControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/AttachmentControllerTest.java @@ -5,6 +5,8 @@ import org.example.vet1177.entities.User; import org.example.vet1177.exception.ForbiddenException; import org.example.vet1177.exception.ResourceNotFoundException; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.security.SecurityConfig; import org.example.vet1177.services.AttachmentService; import org.junit.jupiter.api.BeforeEach; @@ -39,6 +41,12 @@ class AttachmentControllerTest { @MockitoBean private AttachmentService attachmentService; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + private User vetUser; private UUID recordId; private UUID attachmentId; diff --git a/src/test/java/org/example/vet1177/controller/ClinicControllerTest.java b/src/test/java/org/example/vet1177/controller/ClinicControllerTest.java index fe8892f1..c0548091 100644 --- a/src/test/java/org/example/vet1177/controller/ClinicControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/ClinicControllerTest.java @@ -1,6 +1,8 @@ package org.example.vet1177.controller; import org.example.vet1177.entities.Clinic; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.services.ClinicService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -26,6 +28,12 @@ class ClinicControllerTest { @MockitoBean private ClinicService clinicService; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + @Test void shouldCreateClinic() throws Exception { // Arrange diff --git a/src/test/java/org/example/vet1177/controller/CommentControllerTest.java b/src/test/java/org/example/vet1177/controller/CommentControllerTest.java index 37ccdf8b..fe30a6af 100644 --- a/src/test/java/org/example/vet1177/controller/CommentControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/CommentControllerTest.java @@ -15,6 +15,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.security.SecurityConfig; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -45,6 +47,12 @@ class CommentControllerTest { @MockitoBean private CommentService commentService; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + private User currentUser; private MedicalRecord record; private Comment comment; diff --git a/src/test/java/org/example/vet1177/controller/MedicalRecordControllerTest.java b/src/test/java/org/example/vet1177/controller/MedicalRecordControllerTest.java index 2b7d8a14..38b44129 100644 --- a/src/test/java/org/example/vet1177/controller/MedicalRecordControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/MedicalRecordControllerTest.java @@ -15,6 +15,8 @@ import org.example.vet1177.exception.ForbiddenException; import org.example.vet1177.exception.ResourceNotFoundException; import org.example.vet1177.policy.MedicalRecordPolicy; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.security.SecurityConfig; import org.example.vet1177.services.MedicalRecordService; import org.example.vet1177.services.UserService; @@ -59,6 +61,12 @@ class MedicalRecordControllerTest { @MockitoBean private UserService userService; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + private User vetUser; private User ownerUser; private Pet pet; diff --git a/src/test/java/org/example/vet1177/controller/VetControllerTest.java b/src/test/java/org/example/vet1177/controller/VetControllerTest.java index 2ffaa8bc..dc1387ce 100644 --- a/src/test/java/org/example/vet1177/controller/VetControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/VetControllerTest.java @@ -6,6 +6,8 @@ import org.example.vet1177.entities.User; import org.example.vet1177.exception.ForbiddenException; import org.example.vet1177.exception.ResourceNotFoundException; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.policy.AdminPolicy; import org.example.vet1177.services.VetService; import org.junit.jupiter.api.Test; @@ -39,6 +41,12 @@ class VetControllerTest { @MockitoBean private AdminPolicy adminPolicy; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + // ========================= // POST /api/vets // ========================= diff --git a/src/test/java/org/example/vet1177/integration/activitylog/ActivityLogIntegrationTest.java b/src/test/java/org/example/vet1177/integration/activitylog/ActivityLogIntegrationTest.java index 385562fd..7eeb8858 100644 --- a/src/test/java/org/example/vet1177/integration/activitylog/ActivityLogIntegrationTest.java +++ b/src/test/java/org/example/vet1177/integration/activitylog/ActivityLogIntegrationTest.java @@ -1,6 +1,5 @@ package org.example.vet1177.integration.activitylog; -import org.checkerframework.checker.units.qual.C; import org.example.vet1177.config.AwsS3Properties; import org.example.vet1177.entities.*; import org.example.vet1177.integration.TestDataFactory; @@ -10,15 +9,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.math.BigDecimal; -import java.time.LocalDate; import java.util.UUID; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -137,7 +136,9 @@ void should_return_logs_for_owner_only() throws Exception { // Act & Assert mockMvc.perform(get("/api/activity-logs/record/" + record.getId()) - .header("userId", owner.getId().toString())) + .with(authentication(new UsernamePasswordAuthenticationToken( + owner, null, owner.getAuthorities() + )))) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(2)); } @@ -166,7 +167,9 @@ void should_allow_vet_in_same_clinic_to_see_logs() throws Exception { ActivityType.CASE_CREATED, "log"); mockMvc.perform(get("/api/activity-logs/record/" + record.getId()) - .header("userId", vet.getId().toString())) + .with(authentication(new UsernamePasswordAuthenticationToken( + vet, null, vet.getAuthorities() + )))) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(1)); } @@ -197,15 +200,11 @@ void should_filter_out_logs_for_vet_in_other_clinic() throws Exception { ActivityType.CASE_CREATED, "log"); mockMvc.perform(get("/api/activity-logs/record/" + record.getId()) - .header("userId", vetOtherClinic.getId().toString())) + .with(authentication(new UsernamePasswordAuthenticationToken( + vetOtherClinic, null, vetOtherClinic.getAuthorities() + )))) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(0)); } - @Test - void should_return_400_if_userId_missing() throws Exception { - - mockMvc.perform(get("/api/activity-logs/record/" + UUID.randomUUID())) - .andExpect(status().isBadRequest()); - } } diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 88e85bcc..0fcfdc88 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,9 +1,8 @@ # ============================================ -# TEST-profil ? krs av @ActiveProfiles("test") -# Anvnds av integrationstester med @SpringBootTest + # ============================================ -# Separat testdatabas ? aldrig dev-databasen + spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa @@ -13,14 +12,14 @@ spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.datasource.hikari.driver-class-name=org.h2.Driver -# Skapa schema frn scratch och rensa efter tester + spring.jpa.hibernate.ddl-auto=create-drop # Hibernate creates schema automatically (schema.sql is NOT used in tests) spring.sql.init.mode=never spring.sql.init.data-locations= -# Stng av Docker Compose i tester + # CI hanterar databasen som service container spring.docker.compose.lifecycle-management=none @@ -31,8 +30,8 @@ aws.s3.secret-key=minioadmin aws.s3.bucket-name=vet1177-test-attachments aws.s3.region=eu-north-1 -# JWT ? dummyvrden fr tester -jwt.secret=test-secret-key-minst-32-tecken-lang-for-tester + +jwt.secret-key=test-secret-key-minst-32-tecken-lang-for-tester jwt.expiration-ms=86400000 # Mindre output i tester