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
WHO is making the request?
WHAT is that user allowed to do?
WHAT is that user allowed to do?
Our Final Role Rules
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 profileComplete 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 laterBook appointment as PATIENT
View another doctor's schedule
Modify another doctor's profile
View unrelated patient appointmentsDOCTOR cannot :
First Important Security Fix — Appointment Responses
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
{
"patientId": 5
}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 ?
JWT ↓ email ↓ SecurityContextHolder ↓ Authentication
Enable Method Security
3
import org.springframework.security.config.annotation.method.configuration.
EnableMethodSecurity;Now we can use: @PreAuthorize(...)
@Configuration
@EnableMethodSecurity
public class SecurityConfig {Understanding hasRole()
PATIENT ↓ ROLE_PATIENT
DOCTOR ↓ ROLE_DOCTOR
Update DoctorProfileRepository
4
Optional<DoctorProfile> findByUserId( Long userId );boolean existsByUserId(Long userId);
Optional<DoctorProfile> findByUserId(
Long userId
);import java.util.Optional;Secure Doctor Profile Ownership
5
JWT ↓ Current Doctor User ↓ Their DoctorProfile
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
PUT /api/doctors/{doctorId}
DELETE /api/doctors/{doctorId}for profile management.
POST /api/doctors/profile GET /api/doctors/profile/me PUT /api/doctors/profile/me DELETE /api/doctors/profile/me
Update DoctorService
7
@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);
}@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));
}@PreAuthorize("hasRole('DOCTOR')")
public void deleteMyProfile(String email) {
User user = findUserByEmail(email);
DoctorProfile doctor = findDoctorByUserId(user.getId());
doctorRepository.delete(doctor);
}Add Helper Methods
8
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")
);
}Before proceeding to the next steps, we need to clean up the existing DoctorService code by removing outdated methods
Update DoctorController
9
@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);
}@GetMapping("/profile/me")
public ResponseEntity<DoctorResponse>getMyProfile(Authentication authentication) {
return ResponseEntity.ok(doctorService.getMyProfile(authentication.getName())
);
}@PutMapping("/profile/me")
public ResponseEntity<DoctorResponse>updateMyProfile(
@Valid @RequestBody DoctorProfileRequest request, Authentication authentication) {
return ResponseEntity.ok(doctorService.updateMyProfile(
authentication.getName(),request)
);
}@DeleteMapping("/profile/me")
public ResponseEntity<Void> deleteMyProfile(Authentication authentication) {
doctorService.deleteMyProfile(authentication.getName());
return ResponseEntity.noContent().build();
}import org.springframework.security.core.Authentication;Fix Appointment Slot Query
10
existsByDoctorIdAndAppointmentDateAndTimeSlot(...)
boolean existsByDoctorIdAndAppointmentDateAndTimeSlotAndAppointmentStatusNot(
Long doctorId,
LocalDate appointmentDate,
TimeSlot timeSlot,
AppointmentStatus appointmentStatus
);Refactor AppointmentService
11
@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);
}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);
}Secure Appointment by ID
14
@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
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 org.springframework.security.access.AccessDeniedException;Patient Cancel Appointment
16
@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
@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
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()
);
}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
@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);
}@GetMapping("/my")
public ResponseEntity<List<AppointmentResponse>>
getMyAppointments(Authentication authentication) {
return ResponseEntity.ok(appointmentService.getMyAppointments( authentication.getName()
)
);
}@GetMapping("/doctor/me")
public ResponseEntity<List<AppointmentResponse>>
getMyDoctorAppointments(Authentication authentication) {
return ResponseEntity.ok(appointmentService.getMyDoctorAppointments(
authentication.getName()
)
);
}@GetMapping("/{id}")public ResponseEntity<AppointmentResponse>
getAppointmentById( @PathVariable Long id,Authentication authentication) {
return ResponseEntity.ok(appointmentService.getAppointmentById(
id,authentication.getName()
)
);
}@PatchMapping("/{id}/cancel")
public ResponseEntity<AppointmentResponse>
cancelAppointment(@PathVariable Long id, Authentication authentication) {
return ResponseEntity.ok(appointmentService.cancelAppointment(
id,authentication.getName()
)
);
}@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
GET /api/appointments GET /api/appointments/patient/{patientId} GET /api/appointments/doctor/{doctorId}
PATIENT ↓ /appointments/my
DOCTOR ↓ /appointments/doctor/me
Remove Insecure Appointment Endpoints
21
public UserResponse getCurrentUser(String email) {
User user = userRepository.findByEmailIgnoreCase(email).orElseThrow(() ->
new UserNotFoundException("User not found")
);
return mapToUserResponse(user);
}@GetMapping("/me")
public ResponseEntity<UserResponse>getCurrentUser(Authentication authentication) { return ResponseEntity.ok(userService.getCurrentUser(authentication.getName())
);
}{
"id": 1,
"name": "Rahul Shah",
"email": "rahul@example.com",
"phone": "9876543210",
"role": "PATIENT"
}GET /api/users
GET /api/users/{id}Configure Endpoint-Level RBAC
22
from the controller for now.
.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()
)
Why Both SecurityConfig AND @PreAuthorize?
Yes — intentionally for sensitive business actions.
HTTP endpoint:
POST /appointments
↓
SecurityConfig:
PATIENT required
↓
Controller
↓
Service:
@PreAuthorize("hasRole('PATIENT')")Proper 401 and 403 JSON Responses
23
security/ └── CustomAuthenticationEntryPoint.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.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);
}
}Understand Token Generation
.subject(userDetails.getUsername())
rahul@example.com, which identifies the user associated with the token..issuedAt(...) method records the date and time when the token was created..expiration(...) method specifies when the token becomes invalid and can no longer be used for authentication..signWith(getSigningKey()) method digitally signs the token using a secret key to protect its integrityrahul@example.com to admin@example.com, the signature verification fails because the token has been tampered with.Create AuthService
12
service/
└── AuthService.javapackage 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.javaimport 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
service/
└── AuthService.javapackage 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.javaservice/
└── 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
Create JWT Authentication Filter
14
security/
└── JwtAuthenticationFilter.javapackage 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;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;
}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);
}
}Understand Bearer
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;STATELESS session management JWT filter Public auth endpoints
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
Login ↓ Server creates Session ↓ Server remembers User
Login ↓ Server creates JWT ↓ Client holds JWT ↓ Server validates JWT on every request
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 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);
}Test Registration
18
POST http://localhost:8080/api/auth/register
{
"name": "Rahul Shah",
"email": "rahul@example.com",
"phone": "9876543210",
"password": "Rahul@123"
}{
"id": 1,
"name": "Rahul Shah",
"email": "rahul@example.com",
"phone": "9876543210",
"role": "PATIENT"
}Check Password in Database
19
SELECT id, email, password, role FROM users;email: rahul@example.com password: $2a$10$.... role: PATIENT
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:
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
Protected API With JWT
24
Authorization → Bearer Token
GET http://localhost:8080/api/doctors
Modify the Token
25
For example: eyJ...
Change one character in the JWT.
GET /api/doctors
Test Expiration
26
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 branchNameNext-Lab Preparation
Module:
1) Mastering Springboot Security