Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8db8572
feat: add OAuth2 resource server dependency in pom.xml
annikaholmqvist94 Apr 10, 2026
90517ef
feat: add JWT configuration properties in application.properties
annikaholmqvist94 Apr 10, 2026
c8c05ee
feat: add JwtProperties to load JWT configuration from application.pr…
annikaholmqvist94 Apr 10, 2026
56c6df5
feat: add JwtService for handling JWT creation and validation
annikaholmqvist94 Apr 10, 2026
3a6a56b
feat: add JwtAuthenticationFilter for token-based request authentication
annikaholmqvist94 Apr 11, 2026
74ca7b6
feat: configure SecurityConfig for JWT and CORS setup
annikaholmqvist94 Apr 11, 2026
8328c26
feat: implement CustomUserDetailsService for user authentication inte…
annikaholmqvist94 Apr 11, 2026
d606938
CustomUserDetailsService for user authentication integration
annikaholmqvist94 Apr 11, 2026
85928ae
test: add JwtService and CustomUserDetailsService mocks in VetControl…
annikaholmqvist94 Apr 11, 2026
201c64a
test: add JwtService and CustomUserDetailsService mocks in ActivityLo…
annikaholmqvist94 Apr 11, 2026
67000d1
test: add JwtService and CustomUserDetailsService mocks in ClinicCont…
annikaholmqvist94 Apr 11, 2026
295a52a
test: add JwtService and CustomUserDetailsService mocks in CommentCon…
annikaholmqvist94 Apr 11, 2026
aaee79b
test: add JwtService and CustomUserDetailsService mocks in MedicalRec…
annikaholmqvist94 Apr 11, 2026
bdd6c7e
Merge remote-tracking branch 'origin/main' into feat/spring-security-…
annikaholmqvist94 Apr 11, 2026
07bce62
test: add authentication setup in ActivityLogIntegrationTest and Atta…
annikaholmqvist94 Apr 11, 2026
289fbd1
fix: specify UTF-8 charset for secret key generation in JwtService
annikaholmqvist94 Apr 13, 2026
184fbbe
fix: handle UsernameNotFoundException in JwtAuthenticationFilter
annikaholmqvist94 Apr 13, 2026
bc1a131
fix: validate secret key length for HS256 in JwtService
annikaholmqvist94 Apr 13, 2026
1d19d75
refactor: replace @RequestHeader with @AuthenticationPrincipal in Pet…
annikaholmqvist94 Apr 14, 2026
a7d1f66
refactor: use @AuthenticationPrincipal in ActivityLogController
annikaholmqvist94 Apr 14, 2026
c7ae70e
test: update ActivityLogControllerTest for Spring Security setup
annikaholmqvist94 Apr 14, 2026
8e1f665
test: remove redundant userId headers in ActivityLogIntegrationTest
annikaholmqvist94 Apr 14, 2026
7865e17
docs: add comprehensive API usage guide for frontend developers
annikaholmqvist94 Apr 14, 2026
965dcf1
test: clean up comments and update application-test.properties
annikaholmqvist94 Apr 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
812 changes: 812 additions & 0 deletions API.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ActivityLogResponse> 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();
}
}
}
39 changes: 19 additions & 20 deletions src/main/java/org/example/vet1177/controller/PetController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<PetResponse> 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<List<PetResponse>> getPetsByOwner(
@RequestHeader UUID currentUserId,
@AuthenticationPrincipal User currentUser,
@PathVariable UUID ownerId
) {
List<PetResponse> pets = petService.getPetsByOwner(currentUserId, ownerId)
log.info("GET /pets/owner/{}", ownerId);
List<PetResponse> pets = petService.getPetsByOwner(currentUser.getId(), ownerId)
.stream()
.map(this::toResponse)
.toList();
Expand All @@ -69,21 +67,22 @@ public ResponseEntity<List<PetResponse>> getPetsByOwner(
// PUT /pets/{petId} - uppdatera ett djur
@PutMapping("/{petId}")
public ResponseEntity<PetResponse> 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<Void> 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();
}
Expand All @@ -103,4 +102,4 @@ private PetResponse toResponse(Pet pet) {
);
}

}
}
Original file line number Diff line number Diff line change
@@ -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);
});
Comment on lines +44 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid logging email addresses in production logs.

Logging email in the warning message may violate privacy requirements (GDPR/CCPA) as emails are PII. Consider logging a hash or partial email, or removing the email from the log entirely.

🛡️ Suggested fix
         return userRepository.findByEmail(email)
                 .orElseThrow(() -> {
-                    log.warn("User not found email={}", email);
+                    log.warn("User not found for provided email");
                     return new UsernameNotFoundException("Användare hittades inte: " + email);
                 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return userRepository.findByEmail(email)
.orElseThrow(() -> {
log.warn("User not found email={}", email);
return new UsernameNotFoundException("Användare hittades inte: " + email);
});
return userRepository.findByEmail(email)
.orElseThrow(() -> {
log.warn("User not found for provided email");
return new UsernameNotFoundException("Användare hittades inte: " + email);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/vet1177/security/CustomUserDetailsService.java`
around lines 44 - 48, The warning currently logs the raw email in
CustomUserDetailsService inside the userRepository.findByEmail(email)
orElseThrow lambda; remove or replace the raw email with a non-PII
representation (e.g., a deterministic hash or masked partial like user@***), and
update the log call to use that value instead of the email before throwing
UsernameNotFoundException; ensure the hashing/masking logic is implemented near
this code path and does not expose the original email in logs or exception
messages.

}
}
Original file line number Diff line number Diff line change
@@ -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());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Släpp vidare requesten till nästa filter i kedjan (och sedan till controllern)
filterChain.doFilter(request, response);
}
}
22 changes: 22 additions & 0 deletions src/main/java/org/example/vet1177/security/JwtProperties.java
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading