Spring Boot Mastery: Adding Authentication to Journal Endpoints
This chapter focuses on securing the journal endpoints of our Spring Boot application. We will transition from an insecure architecture—where usernames are passed as vulnerable path variables—to a secure, production-ready design leveraging Spring Security's SecurityContextHolder. By the end of this guide, users will only be able to create, read, update, and delete journal entries that strictly belong to their authenticated sessions.
1. The Core Problem: Unsecured Journal Endpoints
In our initial implementation, the application relied on path variables to identify which user was performing an action on a journal entry (e.g., /journal/ram). This approach presents severe security vulnerabilities:
- No Ownership Verification: Any client can access or modify any other user's journal entries simply by changing the username in the URL path.
- Lack of Authentication: The application does not verify if the client making the request is actually the owner of the resource.
- Spoofing Vulnerability: If user
Shyamlogs in, they can easily spoof the system to view or deleteRam's journal entries by manually passingramin the request path.
The Goal
We must redesign the API so that:
- Users authenticate via Basic Authentication (or later, token-based authentication).
- The application automatically resolves the logged-in user's identity from the secure session context.
- Users are strictly restricted to accessing and mutating only their own data.
2. The Solution: Leveraging Spring Security Context
Instead of expecting the client to send the username in the request path or body, we extract the authenticated user's identity directly from the Spring Security Context.
When a client sends a request with valid credentials, Spring Security authenticates the request and populates the SecurityContextHolder. We can then access this context anywhere in our controller layer.
Security Context Extraction Flow
Code Implementation: Extracting the Username
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
By using this approach, we eliminate the need to pass the username in the URL path, making our endpoints cleaner and inherently secure.
3. Database Setup & IP Whitelisting in MongoDB Atlas
Before testing our secured endpoints, we must ensure our database environment is clean and correctly configured.
Real-World Pitfall: Dynamic IP Whitelisting
When developing across different machines or network environments (e.g., switching from macOS to Windows, or changing Wi-Fi networks), your local IP address changes. This can cause connection timeouts or authentication failures with MongoDB Atlas.
Debugging Workflow: Whitelisting your IP in MongoDB Atlas
- Log in to your MongoDB Atlas Console.
- Navigate to Security -> Network Access.
- Click Add IP Address.
- Click Add Current IP Address to whitelist your current development machine's IP, or configure
0.0.0.0/0(not recommended for production) for temporary global access.- Save changes and wait for the cluster to update its firewall rules.
Database Clean Slate
For this implementation, we clean our MongoDB Atlas collections to verify the data flow from scratch:
userscollection: Stores user credentials, roles, and references to journal entries.journal_entriescollection: Stores the actual journal documents.
4. Refactoring User Service: The Password Re-Encoding Bug
During the integration of journal entries with users, a critical bug often arises in the UserService if the saving logic is not properly decoupled.
The Bug Scenario
When a user creates a new journal entry, the application:
- Fetches the
Userdocument from the database. - Appends the new
JournalEntryreference to the user'sjournalEntrieslist. - Saves the updated
Userdocument back to the database.
If the UserService has only one generic save method that encodes passwords:
// NAIVE APPROACH (CAUSES BUG)
public void saveUser(User user) {
user.setPassword(passwordEncoder.encode(user.getPassword())); // Re-encodes!
userRepository.save(user);
}
When saving the user after adding a journal entry, the already-encoded password gets encoded a second time. This corrupts the password hash in the database, locking the user out of the application permanently on their next login attempt.
The Decoupled Solution
We must split our user-saving logic into two distinct methods:
saveNewUser: Encodes the raw password (used during registration).saveUser: Saves the user entity directly without re-encoding the password (used for updating references or profile changes).
package com.edutest.journalApp.service;
import com.edutest.journalApp.entity.User;
import com.edutest.journalApp.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Arrays;
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
private static final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// Used ONLY when creating a brand new user (requires password encoding)
public void saveNewUser(User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setRoles(Arrays.asList("USER"));
userRepository.save(user);
}
// Used for updating existing users without altering/re-encoding the password
public void saveUser(User user) {
userRepository.save(user);
}
}
5. Securing Journal Creation (POST)
To create a journal entry, we no longer pass the username in the path. The endpoint is simply /journal. The controller extracts the authenticated user's name from the security context, saves the journal entry, and links it to that user.
Controller Implementation
@PostMapping
public ResponseEntity<JournalEntry> createEntry(@RequestBody JournalEntry myEntry) {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
journalEntryService.saveEntry(myEntry, username);
return new ResponseEntity<>(myEntry, HttpStatus.CREATED);
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
}
Service Implementation
@Transactional
public void saveEntry(JournalEntry journalEntry, String userName) {
User user = userService.findByUserName(userName);
journalEntry.setDate(LocalDateTime.now());
JournalEntry saved = journalEntryRepository.save(journalEntry);
user.getJournalEntries().add(saved);
userService.saveUser(user); // Uses the safe saveUser method (no re-encoding)
}
6. Securing Journal Retrieval by ID (GET)
When a user requests a specific journal entry by its ID (/journal/id/{myId}), we must verify that the requested entry actually belongs to the authenticated user. If we skip this check, any authenticated user could query random IDs and read other users' private journals.
Verification Logic
- Extract the authenticated username.
- Fetch the
Userobject from the database. - Stream the user's list of journal entries and check if any entry matches the requested
myId. - If a match is found, fetch and return the journal entry. Otherwise, return
404 Not Found.
@GetMapping("id/{myId}")
public ResponseEntity<JournalEntry> getJournalEntryById(@PathVariable ObjectId myId) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String userName = authentication.getName();
User user = userService.findByUserName(userName);
// Verify ownership by checking if the ID exists in the user's journal list
List<JournalEntry> collect = user.getJournalEntries().stream()
.filter(x -> x.getId().equals(myId))
.collect(Collectors.toList());
if (!collect.isEmpty()) {
Optional<JournalEntry> journalEntry = journalEntryService.findById(myId);
if (journalEntry.isPresent()) {
return new ResponseEntity<>(journalEntry.get(), HttpStatus.OK);
}
}
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
7. Securing Journal Deletion (DELETE) with Transactional Integrity
Deleting a journal entry requires a two-step database mutation:
- Remove the reference of the journal entry from the
Userdocument. - Delete the actual journal document from the
journal_entriescollection.
Because this involves multiple database operations, we must mark the service method as @Transactional to prevent data inconsistency if one of the operations fails.
Controller Implementation
@DeleteMapping("id/{myId}")
public ResponseEntity<?> deleteJournalEntryById(@PathVariable ObjectId myId) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
boolean removed = journalEntryService.deleteById(myId, username);
if (removed) {
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
} else {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
Service Implementation
@Transactional
public boolean deleteById(ObjectId id, String userName) {
boolean removed = false;
try {
User user = userService.findByUserName(userName);
// Attempt to remove the entry from the user's list
removed = user.getJournalEntries().removeIf(x -> x.getId().equals(id));
if (removed) {
userService.saveUser(user); // Save user without re-encoding password
journalEntryRepository.deleteById(id); // Delete actual journal document
}
} catch (Exception e) {
log.error("Error occurred while deleting journal entry: ", e);
throw new RuntimeException("An error occurred while deleting the entry.", e);
}
return removed;
}
8. Securing Journal Update (PUT)
Updating a journal entry (/journal/id/{myId}) follows the same security pattern: verify that the journal entry belongs to the authenticated user before applying any updates.
Controller Implementation
@PutMapping("id/{myId}")
public ResponseEntity<?> updateJournalById(@PathVariable ObjectId myId, @RequestBody JournalEntry newEntry) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String userName = authentication.getName();
User user = userService.findByUserName(userName);
// Verify ownership
List<JournalEntry> collect = user.getJournalEntries().stream()
.filter(x -> x.getId().equals(myId))
.collect(Collectors.toList());
if (!collect.isEmpty()) {
Optional<JournalEntry> journalEntry = journalEntryService.findById(myId);
if (journalEntry.isPresent()) {
JournalEntry old = journalEntry.get();
// Update fields if new values are provided
old.setTitle(newEntry.getTitle() != null && !newEntry.getTitle().isEmpty() ? newEntry.getTitle() : old.getTitle());
old.setContent(newEntry.getContent() != null && !newEntry.getContent().isEmpty() ? newEntry.getContent() : old.getContent());
journalEntryService.saveEntry(old); // Saves the updated entry
return new ResponseEntity<>(old, HttpStatus.OK);
}
}
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
9. Complete End-to-End Workflow & Verification
Here is a summary of the secured endpoints, their access patterns, and the security checks performed:
End-to-End Execution Flow
10. Summary and Key Takeaways
- Eliminated Path Variable Vulnerabilities: We removed the insecure practice of passing usernames in URL paths for authenticated resources.
- Leveraged Spring Security Context: By using
SecurityContextHolder.getContext().getAuthentication(), the application securely resolves the logged-in user's identity server-side. - Resolved Password Re-Encoding Bug: We decoupled the user-saving logic into
saveNewUser(which hashes raw passwords) andsaveUser(which updates existing records without re-hashing), preventing account lockout issues. - Strict Ownership Verification: Every CRUD operation on a journal entry now verifies that the target resource ID belongs to the authenticated user's list of entries.
- Transactional Integrity: Deletion operations are marked with
@Transactionalto ensure that both the user's reference list and the journal collection remain synchronized, preventing orphaned documents. - Robust HTTP Status Codes: The API now returns precise status codes (
201 Created,204 No Content, and404 Not Found) depending on ownership and resource existence.