Role-Based Access Control (RBAC) & Ownership Security

Business Scenario

In this lab, we will enhance the Medicare backend by implementing Role-Based Access Control (RBAC) and ownership security using Spring Security and JWT authentication.

Pre-Lab Preparation

Module:

1) Mastering Springboot Security​​

  • In Phase 9, we implemented JWT Authentication.
  • Now JWT tells us:
WHO is making the request?
  • In Phase 10, we implement Role-Based Access Control (RBAC) and Ownership Security. This phase answers:
  • WHAT is that user allowed to do?
  • WHAT is that user allowed to do?

Our Final Role Rules

  • We currently have:
PATIENT
DOCTOR

PATIENT can :

Login
View/search doctors
Book an appointment
View own appointments
Create DoctorProfile
Modify DoctorProfile
View another patient's appointments
View doctor's private schedule

PATIENT cannot :

View own appointment details
Cancel own appointment
View own profile
Complete an appointment
Create prescriptions later

DOCTOR can :

Login
Search/view doctors
Manage own DoctorProfile
View own assigned appointments
View appointment assigned
Mark own appointment COMPLETED
Create prescriptions later
Book appointment as PATIENT
View another doctor's schedule
Modify another doctor's profile
View unrelated patient appointments

DOCTOR cannot :

First Important Security Fix — Appointment Responses

  • This is more than simple RBAC.
  • We also need: OWNERSHIP SECURITY because: A patient having ROLE_PATIENT must not automatically gain access to every patient's data.
  • After Phase 8, our User entity contains: password
  • So we should no longer return Appointment entities directly.
  • Otherwise nested JSON could expose things we don't want
dto/
└─ AppointmentResponse.java

Create AppointmentResponse

1

package com.medicare.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.medicare.entity.AppointmentStatus;
import com.medicare.entity.PaymentStatus;
import com.medicare.entity.TimeSlot;

public class AppointmentResponse {

    private Long id;

    private Long patientId;
    private String patientName;

    private Long doctorId;
    private String doctorName;
    private String specialization;

    private LocalDate appointmentDate;
    private TimeSlot timeSlot;

    private BigDecimal consultationFee;

    private AppointmentStatus appointmentStatus;
    private PaymentStatus paymentStatus;

    private LocalDateTime createdAt;

    public AppointmentResponse(
            Long id,
            Long patientId,
            String patientName,
            Long doctorId,
            String doctorName,
            String specialization,
            LocalDate appointmentDate,
            TimeSlot timeSlot,
            BigDecimal consultationFee,
            AppointmentStatus appointmentStatus,
            PaymentStatus paymentStatus,
            LocalDateTime createdAt) {

        this.id = id;
        this.patientId = patientId;
        this.patientName = patientName;
        this.doctorId = doctorId;
        this.doctorName = doctorName;
        this.specialization = specialization;
        this.appointmentDate = appointmentDate;
        this.timeSlot = timeSlot;
        this.consultationFee = consultationFee;
        this.appointmentStatus = appointmentStatus;
        this.paymentStatus = paymentStatus;
        this.createdAt = createdAt;
    }

    public Long getId() {
        return id;
    }

    public Long getPatientId() {
        return patientId;
    }

    public String getPatientName() {
        return patientName;
    }

    public Long getDoctorId() {
        return doctorId;
    }

    public String getDoctorName() {
        return doctorName;
    }

    public String getSpecialization() {
        return specialization;
    }

    public LocalDate getAppointmentDate() {
        return appointmentDate;
    }
    public TimeSlot getTimeSlot() {
        return timeSlot;
    }

    public BigDecimal getConsultationFee() {
        return consultationFee;
    }

    public AppointmentStatus getAppointmentStatus() {
        return appointmentStatus;
    }

    public PaymentStatus getPaymentStatus() {
        return paymentStatus;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }
}

Fix AppointmentRequest

2

  • Currently it contains: private Long patientId;
  • This is now dangerous because Rahul could login and send:
{
    "patientId": 5
}
  • And attempt to create an appointment for someone else.
  • So remove: private Long patientId; completely.
  • The new request becomes:
package com.medicare.dto;

import java.time.LocalDate;
import com.medicare.entity.TimeSlot;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotNull;

public class AppointmentRequest {
    @NotNull(message = "Doctor id is required")
    private Long doctorId;

    @NotNull(message = "Appointment date is required")
    @FutureOrPresent(message = "Appointment date cannot be in the past")
    private LocalDate appointmentDate;

    @NotNull(message = "Time slot is required")
    private TimeSlot timeSlot;

    public Long getDoctorId() {
        return doctorId;
    }

    public void setDoctorId(Long doctorId) {
        this.doctorId = doctorId;
    }

    public LocalDate getAppointmentDate() {
        return appointmentDate;
    }

    public void setAppointmentDate(
            LocalDate appointmentDate) {
        this.appointmentDate = appointmentDate;
    }

    public TimeSlot getTimeSlot() {
        return timeSlot;
    }
    public void setTimeSlot(
            TimeSlot timeSlot) {

        this.timeSlot = timeSlot;
    }
}

How Do We Know the Patient Now ?

  • Remember:
JWT
 ↓
email
 ↓
SecurityContextHolder
 ↓
Authentication
  • So if Rahul sends: Authorization: Bearer <rahul-token>
  • Spring already knows: rahul@example.com
  • The frontend no longer tells us: "I am patient 5"
  • The backend determines who the user actually is and this approach is much safer.

Enable Method Security

3

  • Open : SecurityConfig.java
  • Add:
import org.springframework.security.config.annotation.method.configuration.
EnableMethodSecurity;
  • Then:
  • Now we can use: @PreAuthorize(...)

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
  • Spring Security's current documentation recommends @EnableMethodSecurity; once enabled, @PreAuthorize can guard Spring-managed methods.

Understanding hasRole()

PATIENT
 ↓
ROLE_PATIENT
  • and:
DOCTOR
 ↓
ROLE_DOCTOR
  • When using: hasRole("PATIENT") do not write: hasRole("ROLE_PATIENT") because hasRole automatically applies the usual ROLE_ prefix. 

Update DoctorProfileRepository

4

  • We'll need to find the doctor profile belonging to the logged-in doctor.
  • Add:
Optional<DoctorProfile> findByUserId( Long userId );
  • So repository now includes:
boolean existsByUserId(Long userId);

Optional<DoctorProfile> findByUserId(
        Long userId
);
  • Import:
import java.util.Optional;

Secure Doctor Profile Ownership

5

  • Our old DoctorProfileRequest contained: userId
  • Remove it.
  • Doctor should never tell the backend:Update Doctor User 5.
JWT
 ↓
Current Doctor User
 ↓
Their DoctorProfile
  • New request:
package com.medicare.dto;
import java.math.BigDecimal;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

public class DoctorProfileRequest {

    @NotBlank(message = "Specialization is required")
    @Size(max = 100)
    private String specialization;

    @NotNull(message = "Experience is required")
    @Min(0)
    @Max(60)
    private Integer experienceYears;

    @NotNull(message = "Consultation fee is required")
    @Positive
    private BigDecimal consultationFee;

    @Size(max = 500)
    private String photoUrl;

    public String getSpecialization() {
        return specialization;
    }

    public void setSpecialization(
            String specialization) {
        this.specialization = specialization;
    }
    public Integer getExperienceYears() {
        return experienceYears;
    }

    public void setExperienceYears(
            Integer experienceYears) {
        this.experienceYears = experienceYears;
    }

    public BigDecimal getConsultationFee() {
        return consultationFee;
    }

    public void setConsultationFee(
            BigDecimal consultationFee) {
        this.consultationFee = consultationFee;
    }

    public String getPhotoUrl() {
        return photoUrl;
    }

    public void setPhotoUrl(
            String photoUrl) {

        this.photoUrl = photoUrl;
    }
}

Doctor Profile Endpoints Change

6

  • Remove the insecure approach:
PUT /api/doctors/{doctorId}
DELETE /api/doctors/{doctorId}

for profile management.

  • Use:
POST   /api/doctors/profile
GET    /api/doctors/profile/me
PUT    /api/doctors/profile/me
DELETE /api/doctors/profile/me
  • Meaning: "Operate on MY doctor profile."
  • No doctor ID required.

Update DoctorService

7

  • Add methods such as:
@PreAuthorize("hasRole('DOCTOR')")
public DoctorResponse createMyProfile(DoctorProfileRequest request,String email) {

    User user = findUserByEmail(email);

    if (doctorRepository.existsByUserId(user.getId())) {
        throw new IllegalArgumentException( "Doctor profile already exists");
    }

    DoctorProfile doctor = new DoctorProfile();
    doctor.setUser(user);
    doctor.setSpecialization(request.getSpecialization());
    doctor.setExperienceYears(request.getExperienceYears());
    doctor.setConsultationFee(request.getConsultationFee());
    doctor.setPhotoUrl(request.getPhotoUrl());

    return mapToDoctorResponse(doctorRepository.save(doctor));
}
@PreAuthorize("hasRole('DOCTOR')")
public DoctorResponse getMyProfile(String email) {
    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());
    
    return mapToDoctorResponse(doctor);
}
  • Add getMyProfile
  • Add updateMyProfile
@PreAuthorize("hasRole('DOCTOR')")
public DoctorResponse updateMyProfile(String email,DoctorProfileRequest request) {
    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());

    doctor.setSpecialization(request.getSpecialization());

    doctor.setExperienceYears(request.getExperienceYears());

    doctor.setConsultationFee(request.getConsultationFee());

    doctor.setPhotoUrl(request.getPhotoUrl());

    return mapToDoctorResponse(doctorRepository.save(doctor));
}
  • Add deleteMyProfile
@PreAuthorize("hasRole('DOCTOR')")
public void deleteMyProfile(String email) {

    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());
    doctorRepository.delete(doctor);
}

Add Helper Methods

8

  • Inside DoctorService:
private User findUserByEmail(String email) {
    return userRepository.findByEmailIgnoreCase(email).orElseThrow(() ->
                    new UserNotFoundException("User not found")
            );
}
private DoctorProfile findDoctorByUserId(Long userId) {
    return doctorRepository.findByUserId(userId).orElseThrow(() ->
                    new DoctorNotFoundException("Doctor profile not found")
            );
}
  • Add

Before proceeding to the next steps, we need to clean up the existing DoctorService code by removing outdated methods

  • Fix 1 — Delete the old createDoctor()
  • Fix 2 — Remove old updateDoctor() and deleteDoctor()
  • Fix 1 — Delete the old createDoctor()
  • Fix 3 — Remove findUserById()
  • Fix 4 — Remove unused Role import

Update DoctorController

9

  • Add
@PostMapping("/profile")
public ResponseEntity<DoctorResponse>createMyProfile(
                @Valid @RequestBodyDoctorProfileRequest request,
                Authentication authentication) {

    DoctorResponse response = doctorService.createMyProfile(request,
                    authentication.getName());
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
  • Get own profile:
@GetMapping("/profile/me")
public ResponseEntity<DoctorResponse>getMyProfile(Authentication authentication) {
    return ResponseEntity.ok(doctorService.getMyProfile(authentication.getName())
    );
}
  • Update:
@PutMapping("/profile/me")
public ResponseEntity<DoctorResponse>updateMyProfile(
                @Valid @RequestBody DoctorProfileRequest request,
                Authentication authentication) {

    return ResponseEntity.ok(doctorService.updateMyProfile(
                    authentication.getName(),request)
    );
}
  • Delete
@DeleteMapping("/profile/me")
public ResponseEntity<Void> deleteMyProfile(Authentication authentication) {
    doctorService.deleteMyProfile(authentication.getName());
    return ResponseEntity.noContent().build();
}
  • Import :
import org.springframework.security.core.Authentication;

Fix Appointment Slot Query

10

  • Now that patients can cancel appointments, a cancelled slot should become available again.
  • Change
existsByDoctorIdAndAppointmentDateAndTimeSlot(...)
  • To:
boolean existsByDoctorIdAndAppointmentDateAndTimeSlotAndAppointmentStatusNot(
        Long doctorId,
        LocalDate appointmentDate,
        TimeSlot timeSlot,
        AppointmentStatus appointmentStatus
);
  • Then we'll check: AppointmentStatus.CANCELLED

Refactor AppointmentService

11

  • Our service will now use the authenticated email.
  • Create Appointment
@PreAuthorize("hasRole('PATIENT')")
public AppointmentResponse createAppointment(AppointmentRequest request,
                    String email) {

    User patient = findUserByEmail(email);
    DoctorProfile doctor = doctorRepository.findById(request.getDoctorId())
                    .orElseThrow(() -> new DoctorNotFoundException(
                           "Doctor not found with id: "+ request.getDoctorId()
                            )
                    );
    boolean booked = appointmentRepository
     .existsByDoctorIdAndAppointmentDateAndTimeSlotAndAppointmentStatusNot(
     doctor.getId(),request.getAppointmentDate(),request.getTimeSlot(),
     AppointmentStatus.CANCELLED);
    if (booked) {
        throw new AppointmentConflictException(
        "Selected appointment slot is already booked");
    }

    Appointment appointment = new Appointment();
    appointment.setPatient(patient);
    appointment.setDoctor(doctor);
    appointment.setAppointmentDate(request.getAppointmentDate());
    appointment.setTimeSlot(request.getTimeSlot());
    appointment.setConsultationFee(doctor.getConsultationFee());
    appointment.setAppointmentStatus(AppointmentStatus.PENDING);
    appointment.setPaymentStatus(PaymentStatus.UNPAID);
    Appointment savedAppointment = appointmentRepository.save(appointment);

    return mapToAppointmentResponse(savedAppointment);
}

Patient — My Appointments

12

@PreAuthorize("hasRole('PATIENT')")
public List<AppointmentResponse>
        getMyAppointments(String email) {
    User patient = findUserByEmail(email);

    List<Appointment> appointments = appointmentRepository
                    .findByPatientId(patient.getId());

    return mapAppointmentList(appointments);
}
  • Now the endpoint can simply be: GET /api/appointments/my instead of /api/appointments/patient/5

Doctor — My Schedule

13

@PreAuthorize("hasRole('DOCTOR')")
public List<AppointmentResponse>
        getMyDoctorAppointments(
                String email) {

    User user = findUserByEmail(email);

    DoctorProfile doctor = findDoctorByUserId(user.getId());
    List<Appointment> appointments = appointmentRepository
                    .findByDoctorId(doctor.getId());
                    

    return mapAppointmentList(appointments);
    
}
  • Endpoint: GET /api/appointments/doctor/me
  • No doctor ID from frontend.

Secure Appointment by ID

14

  • Suppose Rahul owns Appointment 10.
  • Another patient tries: GET /api/appointments/10
  • Having: ROLE_PATIENT isn't enough.
  • We must verify ownership.
@PreAuthorize("hasAnyRole('PATIENT', 'DOCTOR')")
public AppointmentResponse getAppointmentById( Long appointmentId,String email) {

    Appointment appointment = findAppointmentById(appointmentId);

    User user = findUserByEmail(email);
    verifyAppointmentAccess(appointment,user);

    return mapToAppointmentResponse(appointment);
}

Ownership Check

15

  • Add :
private void verifyAppointmentAccess(Appointment appointment,User user) {

    if (user.getRole() == Role.PATIENT) {
        if (!appointment.getPatient().getId().equals(user.getId())) {
            throw new AccessDeniedException( "You cannot access this appointment");
        }
        return;
    }

    if (user.getRole() == Role.DOCTOR) {
    
        DoctorProfile doctor = findDoctorByUserId(user.getId());

        if (!appointment.getDoctor().getId().equals(doctor.getId())) {
            throw new AccessDeniedException("You cannot access this appointment");
        }
    }
}
  • Import:
import org.springframework.security.access.AccessDeniedException;
  • Spring Security uses an access-denied exception to represent failed authorization, which results in a 403 during an HTTP request.

Patient Cancel Appointment

16

  • Add :
@PreAuthorize("hasRole('PATIENT')")
public AppointmentResponse cancelAppointment(Long appointmentId,String email) {

    User patient = findUserByEmail(email);
    Appointment appointment = findAppointmentById(appointmentId);

    if (!appointment.getPatient().getId().equals(patient.getId())) {
        throw new AccessDeniedException("You cannot cancel this appointment");
    }

    if (appointment.getAppointmentStatus()== AppointmentStatus.COMPLETED) {
        throw new IllegalArgumentException(
        "Completed appointment cannot be cancelled");
    }

    if (appointment.getAppointmentStatus() == AppointmentStatus.CANCELLED) {
        throw new IllegalArgumentException("Appointment is already cancelled");
    }

    appointment.setAppointmentStatus(AppointmentStatus.CANCELLED);
    
    return mapToAppointmentResponse(appointmentRepository.save(appointment));
}

Doctor Complete Appointment

17

  • Add :
@PreAuthorize("hasRole('DOCTOR')")
public AppointmentResponse completeAppointment(Long appointmentId,String email) {

    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());
    Appointment appointment = findAppointmentById(appointmentId);

    if (!appointment.getDoctor().getId().equals(doctor.getId())) {
        throw new AccessDeniedException("You cannot complete this appointment");
    }

    if (appointment.getAppointmentStatus() == AppointmentStatus.CANCELLED) {
      throw new IllegalArgumentException("Cancelled appointment cannot be completed");
    }

    appointment.setAppointmentStatus(AppointmentStatus.COMPLETED);

    return mapToAppointmentResponse(appointmentRepository.save(appointment)
    );
}
    return mapToAppointmentResponse(appointmentRepository.save(appointment)
    );
}

Appointment Mapping

18

  • Add :
private AppointmentResponse mapToAppointmentResponse(Appointment appointment) {

    return new AppointmentResponse(appointment
           .getId(),appointment.getPatient().getId(),
            appointment.getPatient().getName(),
            appointment.getDoctor().getId(),
            appointment.getDoctor().getUser().getName(),
            appointment.getDoctor().getSpecialization(),
            
            appointment.getAppointmentDate(),
            appointment.getTimeSlot(),
            appointment.getConsultationFee(),
            appointment.getAppointmentStatus(),
            appointment.getPaymentStatus(),
            appointment.getCreatedAt()
    );
}
  • List mapper:
private List<AppointmentResponse>mapAppointmentList(
                List<Appointment> appointments) {

    List<AppointmentResponse> responses = new ArrayList<>();

    for (Appointment appointment : appointments) {

        responses.add(mapToAppointmentResponse(appointment));
    }

    return responses;
}

Update AppointmentController

19

  • Our appointment controller becomes much safer.
@PostMapping
public ResponseEntity<AppointmentResponse>
        createAppointment(@Valid @RequestBody AppointmentRequest request,
                Authentication authentication) {

    AppointmentResponse response = appointmentService.createAppointment(
                            request,authentication.getName()
                    );

    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
  • Patient appointments
@GetMapping("/my")
public ResponseEntity<List<AppointmentResponse>>
        getMyAppointments(Authentication authentication) {
    return ResponseEntity.ok(appointmentService.getMyAppointments(
  authentication.getName()
                    )
    );
}
  • Doctor schedule
@GetMapping("/doctor/me")
public ResponseEntity<List<AppointmentResponse>>
        getMyDoctorAppointments(Authentication authentication) {
    return ResponseEntity.ok(appointmentService.getMyDoctorAppointments(
                            authentication.getName()
                    )
    );
}
@GetMapping("/{id}")
  • Doctor schedule
public ResponseEntity<AppointmentResponse>
        getAppointmentById( @PathVariable Long id,Authentication authentication) {

    return ResponseEntity.ok(appointmentService.getAppointmentById(
                            id,authentication.getName()
                    )
    );
}
  • Cancel
@PatchMapping("/{id}/cancel")
public ResponseEntity<AppointmentResponse>
        cancelAppointment(@PathVariable Long id, Authentication authentication) {

    return ResponseEntity.ok(appointmentService.cancelAppointment(
                            id,authentication.getName()
                    )
    );
}
  • Complete
@PatchMapping("/{id}/complete")
public ResponseEntity<AppointmentResponse>
        completeAppointment(@PathVariable Long id,Authentication authentication) {
    return ResponseEntity.ok(appointmentService.completeAppointment(
                            id,authentication.getName()
                    )
    );
}

Remove Insecure Appointment Endpoints

20

  • Remove:
GET /api/appointments
GET /api/appointments/patient/{patientId}
GET /api/appointments/doctor/{doctorId}
  • Because these make ID manipulation much easier.
  • Now:
PATIENT
 ↓
/appointments/my
  • And:
DOCTOR
 ↓
/appointments/doctor/me

Remove Insecure Appointment Endpoints

21

  • Previously: GET /api/users/me just showed username/authorities.
  • Now make it useful for React.
  • UserService:
public UserResponse getCurrentUser(String email) {

    User user = userRepository.findByEmailIgnoreCase(email).orElseThrow(() ->
                            new UserNotFoundException("User not found")
                    );
                    
    return mapToUserResponse(user);
}
  • Controller:
@GetMapping("/me")
public ResponseEntity<UserResponse>getCurrentUser(Authentication authentication) {
    return ResponseEntity.ok(userService.getCurrentUser(authentication.getName())
    );
}
  • Result:
{
    "id": 1,
    "name": "Rahul Shah",
    "email": "rahul@example.com",
    "phone": "9876543210",
    "role": "PATIENT"
}
  • Remove public:
GET /api/users
GET /api/users/{id}

Configure Endpoint-Level RBAC

22

  • Now update the authorization section in SecurityConfig.

from the controller for now.

  • There is no business reason for patients/doctors to list every user.
  • Order matters: Spring Security evaluates matching authorization rules in the declared order.
.authorizeHttpRequests(auth -> auth

        // Public APIs
        .requestMatchers("/api/health","/api/auth/register","/api/auth/login")
        .permitAll()

        // Current user's profile
        .requestMatchers("/api/users/me").authenticated()

        // Doctor can manage only own profile
        .requestMatchers("/api/doctors/profile","/api/doctors/profile/**")
        .hasRole("DOCTOR")

        // Doctor search/listing
        .requestMatchers(HttpMethod.GET,"/api/doctors/**")
        .hasAnyRole("PATIENT", "DOCTOR")

        // Patient books appointments
        .requestMatchers(HttpMethod.POST,"/api/appointments")
        .hasRole("PATIENT")

        // Patient appointment history
        .requestMatchers("/api/appointments/my").hasRole("PATIENT")

        // Doctor schedule
        .requestMatchers("/api/appointments/doctor/**").hasRole("DOCTOR")

        // Patient cancellation
        .requestMatchers(HttpMethod.PATCH,"/api/appointments/*/cancel")
        .hasRole("PATIENT")

        // Doctor completion
        .requestMatchers(HttpMethod.PATCH,"/api/appointments/*/complete")
        .hasRole("DOCTOR")

        // Appointment details
        .requestMatchers(HttpMethod.GET,"/api/appointments/*")
        .hasAnyRole("PATIENT", "DOCTOR")

        /*
         * Safer default: Anything we forgot to configure is denied.
         */
         
        .anyRequest()
        .denyAll()
        
)
  • Using a deny-by-default fallback helps avoid accidentally exposing new endpoints that were forgotten in the security configuration.
  • Spring Security's documentation explicitly lists denyAll as a supported authorization rule and shows it as a defensive default.

Why Both SecurityConfig AND @PreAuthorize?

  • You might ask: Aren't we checking the role twice?

Yes — intentionally for sensitive business actions.

  • For Example :
HTTP endpoint:
POST /appointments
      ↓
SecurityConfig:
PATIENT required
      ↓
Controller
      ↓
Service:
@PreAuthorize("hasRole('PATIENT')")
  • Why? -- Because someday another service could call: appointmentService.createAppointment(...) without coming through that controller.
  • Method security protects the business logic too.

Proper 401 and 403 JSON Responses

23

  • One issue:ResponseEntity works inside controllers.
security/
└── CustomAuthenticationEntryPoint.java
  • But: 401 and  403 often happen before the controller runs.
  • So our normal: @RestControllerAdvice is not the right place for every security-filter failure.
  • We'll create dedicated security handlers.
  • Create CustomAuthenticationEntryPoint
package com.medicare.security;

import java.io.IOException;
import java.time.LocalDateTime;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import com.medicare.response.ErrorResponse;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

@Component
public class CustomAuthenticationEntryPoint
        implements AuthenticationEntryPoint {

    private final JsonMapper jsonMapper;

    public CustomAuthenticationEntryPoint(
            JsonMapper jsonMapper) {

        this.jsonMapper = jsonMapper;
    }

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception)
            throws IOException {

        ErrorResponse errorResponse =
                new ErrorResponse(
                        LocalDateTime.now(),
                        HttpStatus.UNAUTHORIZED.value(),
                        "Authentication is required",
                        request.getRequestURI()
                );

        response.setStatus(
                HttpStatus.UNAUTHORIZED.value()
        );

        response.setContentType(
                MediaType.APPLICATION_JSON_VALUE
        );

        response.getWriter().write(
                jsonMapper.writeValueAsString(
                        errorResponse
                )
        );
    }
}
import java.io.IOException;
import java.time.LocalDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import com.medicare.response.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final JsonMapper jsonMapper;
    public CustomAuthenticationEntryPoint(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
    }

    @Override
    public void commence(HttpServletRequest request,HttpServletResponse response,
            AuthenticationException exception) throws IOException {

        ErrorResponse errorResponse = new ErrorResponse(
                        LocalDateTime.now(),
                        HttpStatus.UNAUTHORIZED.value(),
                        "Authentication is required",
                        request.getRequestURI()
                );
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(jsonMapper.writeValueAsString(errorResponse));
    }
}

Create CustomAccessDeniedHandler

24

security/
└── CustomAccessDeniedHandler.java
package com.medicare.security;
import java.io.IOException;
import java.time.LocalDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import com.medicare.response.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final JsonMapper jsonMapper;

    public CustomAccessDeniedHandler(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
    }

    @Override
    public void handle(
            HttpServletRequest request,HttpServletResponse response,
            AccessDeniedException exception) throws IOException {

        ErrorResponse errorResponse = new ErrorResponse(
                        LocalDateTime.now(), HttpStatus.FORBIDDEN.value(),
                        "You do not have permission to access this resource",
                        request.getRequestURI()
                );

        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(jsonMapper.writeValueAsString(errorResponse)
        );
    }
}
@Service
public class JwtService {

    @Value("${jwt.secret}")
    private String jwtSecret;

    @Value("${jwt.expiration}")
    private long jwtExpiration;

    public String generateToken(v  UserDetails userDetails) {

        long currentTime = System.currentTimeMillis();

        return Jwts.builder().subject(userDetails.getUsername())
                .issuedAt(new Date(currentTime))
                .expiration(new Date(currentTime+ jwtExpiration))
                .signWith(getSigningKey()).compact();
    }

    public String extractUsername(String token) {
        return extractAllClaims(token).getSubject();
    }

    public boolean isTokenValid(String token,UserDetails userDetails) {

        String username = extractUsername(token);

        return username.equals(userDetails.getUsername())
                && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {

        Date expiration = extractAllClaims(token).getExpiration();

        return expiration.before(new Date());
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser().verifyWith(getSigningKey())
                .build().parseSignedClaims(token).getPayload();
    }

    private SecretKey getSigningKey() {

        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);

        return Keys.hmacShaKeyFor(keyBytes);
    }
}
  • JJWT's current API recommends signWith(key) for signing and verifyWith(secretKey) plus parseSignedClaims(...) for reading signed claim JWTs.

Understand Token Generation

  • This: ​
.subject(userDetails.getUsername())
  • means: JWT subject  = user email
  • For Rahul, the JWT subject is set to rahul@example.com, which identifies the user associated with the token.
  • The .issuedAt(...) method records the date and time when the token was created.
  • The .expiration(...) method specifies when the token becomes invalid and can no longer be used for authentication.
  • The .signWith(getSigningKey()) method digitally signs the token using a secret key to protect its integrity
  • If someone modifies the email in the token from rahul@example.com to admin@example.com, the signature verification fails because the token has been tampered with.

Create AuthService

12

service/
└── AuthService.java
package com.medicare.service;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import com.medicare.dto.AuthResponse;
import com.medicare.dto.LoginRequest;
import com.medicare.dto.RegisterRequest;
import com.medicare.dto.UserResponse;
import com.medicare.entity.Role;
import com.medicare.entity.User;
import com.medicare.exception.EmailAlreadyExistsException;
import com.medicare.exception.UserNotFoundException;
import com.medicare.repository.UserRepository;
service/
└── AuthService.java
import com.medicare.security.JwtService;
@Service
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;

    public AuthService(UserRepository userRepository,PasswordEncoder passwordEncoder,
            AuthenticationManager authenticationManager,JwtService jwtService) {

        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.authenticationManager = authenticationManager;
        this.jwtService = jwtService;
    }

    public UserResponse register(RegisterRequest request) {

        String email = request.getEmail().trim().toLowerCase();

        if (userRepository.existsByEmailIgnoreCase(email)) {
            throw new EmailAlreadyExistsException("Email is already registered");
        }

        User user = new User();

        user.setName(request.getName().trim());
service/
└── AuthService.java
        user.setEmail(email);

        user.setPhone(request.getPhone());

        user.setPassword(passwordEncoder.encode(request.getPassword()));

        /*
         * Public registration always creates
         * a PATIENT account.
         */
        user.setRole(Role.PATIENT);
        User savedUser = userRepository.save(user);

        return mapToUserResponse(savedUser);
    }

    public AuthResponse login(LoginRequest request) {

        String email = request.getEmail().trim().toLowerCase();

        /*
         * AuthenticationManager checks
         * email and password.
         */
        var authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(email,
                       request.getPassword()));

        UserDetails userDetails = (UserDetails)authentication.getPrincipal();
service/
└── AuthService.java
        User user = userRepository.findByEmailIgnoreCase(userDetails.getUsername())
                        .orElseThrow(() -> new UserNotFoundException(
                                        "User not found"));

        String token = jwtService.generateToken(userDetails);

        return new AuthResponse(
                token,user.getId(),user.getName(),user.getEmail(),user.getRole()
        );
    }

    private UserResponse mapToUserResponse(User user) {

        return new UserResponse(
                user.getId(),
                user.getName(),
                user.getEmail(),
                user.getPhone(),
                user.getRole()
        );
    }
}

Create AuthController

13

  • Create
service/
└── AuthService.java
package com.medicare.controller;
import org.springframework.http.H ttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.medicare.dto.AuthResponse;
import com.medicare.dto.LoginRequest;
import com.medicare.dto.RegisterRequest;
import com.medicare.dto.UserResponse;
import com.medicare.service.AuthService;

import jakarta.validation.Valid;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;
controller/
└── AuthController.java
service/
└── AuthService.java
    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/register")
    public ResponseEntity<UserResponse>
            register(@Valid @RequestBody RegisterRequest request) {

        UserResponse response = authService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse>
            login( @Valid @RequestBody LoginRequest request) {
        return ResponseEntity.ok(authService.login(request)
        );
    }
}

Remove Public User Creation

  • Previously we temporarily had: POST /api/users for Phase 8.
  • Now remove the POST method from UserController
  • Registration now happens only through: POST /api/auth/register
  • Also remove UserRequest.java if it is no longer used.

Create JWT Authentication Filter

14

  • Create
security/
└── JwtAuthenticationFilter.java
package com.medicare.security;
import java.io.IOException;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.
UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.
WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
  • Create
security/
└── JwtAuthenticationFilter.java
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    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 {

        String authHeader = request.getHeader( HttpHeaders.AUTHORIZATION);
        /*
         * No Bearer token?
         * Continue the filter chain.
         */
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request,response);
            return;
        }
  • Create
security/
└── JwtAuthenticationFilter.java
        String token = authHeader.substring(7);

        try {
            String username = jwtService.extractUsername(token);

            if (username != null && SecurityContextHolder
                            .getContext().getAuthentication() == null) {

                UserDetails userDetails = userDetailsService
                     .loadUserByUsername(username);

                if (jwtService.isTokenValid(token,userDetails)) {

                    UsernamePasswordAuthenticationToken
                            authenticationToken = 
                                new UsernamePasswordAuthenticationToken(
                                    userDetails,null,userDetails
                                            .getAuthorities()
                            );

                    authenticationToken.setDetails(
                            new WebAuthenticationDetailsSource()
                                    .buildDetails(request)
                    );

                    SecurityContextHolder.getContext()
                            .setAuthentication(authenticationToken);
                }
            }
        } catch ( JwtException | IllegalArgumentException exception) {
            /*
             * Invalid / expired / malformed token.
             * Leave the user unauthenticated.
             */
            SecurityContextHolder.clearContext();
        }
        filterChain.doFilter(request,response);
    }
}
  • Spring Security supports custom filters through addFilterBefore, addFilterAfter, or addFilterAt, and OncePerRequestFilter is specifically designed for filters that should run once per request.

Understand Bearer

  • Request: Authorization: Bearer eyJhbGciOi...
  • The filter checks: authHeader.startsWith("Bearer ")
  • Then: authHeader.substring(7) removes: Bearer leaving only: eyJhbGciOi...

Filter Logic

Request
   ↓
Authorization header exists?
   │
   ├── NO → continue
   │
   └── YES
        ↓
Starts with Bearer?
        ↓
Extract JWT
        ↓
Extract Email
        ↓
Load UserDetails
        ↓
Validate Signature
        ↓
Validate Expiration
        ↓
Valid?
   ┌────┴────┐
   NO       YES
   │         │
   ▼         ▼
No Auth   Authentication object
             ↓
      SecurityContextHolder

Update SecurityConfig

15

package com.medicare.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
  • Now remove: .httpBasic(...) -> We don't want HTTP Basic anymore.
  • Add:
STATELESS session management
JWT filter
Public auth endpoints
  • Final relevant configuration:
import org.springframework.security.authentication.AuthenticationProvider;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.
UsernamePasswordAuthenticationFilter;
import com.medicare.security.CustomUserDetailsService;
import com.medicare.security.JwtAuthenticationFilter;

@Configuration
public class SecurityConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationProvider authenticationProvider(
            CustomUserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) {

        DaoAuthenticationProvider provider =new DaoAuthenticationProvider(
                        userDetailsService );
        provider.setPasswordEncoder(passwordEncoder);

        return provider;
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration configuration)
            throws Exception {

        return configuration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            AuthenticationProvider authenticationProvider,
            JwtAuthenticationFilter jwtAuthenticationFilter)
            throws Exception {
        http.csrf(csrf ->csrf.disable())

                /*
                 * JWT authentication is stateless.
                 * Spring Security should not create
                 * an authentication session.
                 */
                .sessionManagement(session ->session.sessionCreationPolicy(
                                SessionCreationPolicy.STATELESS)
                )
                .authenticationProvider(authenticationProvider)

                .authorizeHttpRequests(auth ->auth

                                .requestMatchers("/api/health").permitAll()

                          .requestMatchers("/api/auth/register","/api/auth/login")
                          .permitAll().anyRequest().authenticated()
                )

                /*
                 * Return 401 when authentication
                 * is missing or invalid.
                 */
                .exceptionHandling(exception -> exception.authenticationEntryPoint(
                               new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)
                                )
                )
                /*
                 * JWT must be checked before
                 * username/password authentication filter.
                 */
                .addFilterBefore(jwtAuthenticationFilter,
                 UsernamePasswordAuthenticationFilter.class
                );
        return http.build();
    }
}

Filter Logic

  • Traditional session:
Login
 ↓
Server creates Session
 ↓
Server remembers User
  • JWT:
Login
 ↓
Server creates JWT
 ↓
Client holds JWT
 ↓
Server validates JWT on every request
  • So: Server-side login session
  • That is why: SessionCreationPolicy.STATELESS is important.

Update Global Exception Handler

16

@ExceptionHandler(EmailAlreadyExistsException.class)

public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(
                EmailAlreadyExistsException exception,
                HttpServletRequest request) {

    ErrorResponse errorResponse = new ErrorResponse(
                    LocalDateTime.now(),
                    HttpStatus.CONFLICT.value(),
                    exception.getMessage(),
                    request.getRequestURI()
            );

    return ResponseEntity
            .status(HttpStatus.CONFLICT).body(errorResponse);
}
  • Handle duplicate email.

Handle Wrong Login Credentials

17

@ExceptionHandler( BadCredentialsException.class)
public ResponseEntity<ErrorResponse>
        handleBadCredentials(
                BadCredentialsException exception,
                HttpServletRequest request) {

    ErrorResponse errorResponse = new ErrorResponse(
                    LocalDateTime.now(),
                    HttpStatus.UNAUTHORIZED.value(),
                    "Invalid email or password",
                    request.getRequestURI()
            );

    return ResponseEntity
            .status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}
  • AuthenticationManager will throw an authentication exception when credentials are wrong.
  • Now: Wrong login → 401 rather than some uncontrolled server error.

Test Registration

18

POST http://localhost:8080/api/auth/register
  • Body :
{
    "name": "Rahul Shah",
    "email": "rahul@example.com",
    "phone": "9876543210",
    "password": "Rahul@123"
}
  • Notice: role is not supplied.
  • Expected: 201 CREATED
  • Response:
{
    "id": 1,
    "name": "Rahul Shah",
    "email": "rahul@example.com",
    "phone": "9876543210",
    "role": "PATIENT"
}
  • Public users cannot choose their own role anymore.

Check Password in Database

19

  • Run :
SELECT id, email, password, role FROM users;
  • Expected:
email: rahul@example.com

password: $2a$10$....

role: PATIENT
  • Password must still be BCrypt encoded.

Duplicate Registration

20

  • Expected: 409 CONFLICT

{
    "timestamp": "...",
    "status": 409,
    "message": "Email is already registered",
    "path": "/api/auth/register"
}
  • Send the same email again.

  • Expected: 409 CONFLICT

Test Login

21

POST http://localhost:8080/api/auth/login
{
    "email": "rahul@example.com",
    "password": "Rahul@123"
}
  • Body:

  • Expected: 200 OK

Test Wrong password

22

{
    "email": "rahul@example.com",
    "password": "WrongPassword"
}
{
    "status": 401,
    "message": "Invalid email or password"
}
  • Expected: 401 UNAUTHORIZED

Protected API Without JWT

23

GET http://localhost:8080/api/doctors
  • No Authorization header.
  • Expected: 401 UNAUTHORIZED

Protected API With JWT

24

Authorization
→ Bearer Token
  • Paste JWT.
  • Then:
  • Postman :
GET http://localhost:8080/api/doctors
  • Expected: 200 OK

Modify the Token

25

  • For example: eyJ...

  • Change one character in the JWT.

  • to: eyX...
  • Then request:
GET /api/doctors
  • Expected: 401 UNAUTHORIZED
  • Because: Signature verification fails

Test Expiration

26

  • Normally: jwt.expiration=86400000 means about 24 hours.
  • For testing only, temporarily change to :  jwt.expiration=60000
  • Login and Wait until the token has expired.
  • Request protected API.
  • Expected: 401 UNAUTHORIZED
  • Then return: jwt.expiration=86400000

We are done with this lab. The latest source code has been uploaded to GitHub. You can access the latest commit using the link below: 

 

Great job!

You have successfully implemented stateless JWT authentication in the Medicare backend.

Checkpoint

   Git Push

git push origin branchName

Next-Lab Preparation

Module:

1) Mastering Springboot Security​​