Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.
Week 1 - Setup Pet Clinic App. Deployment using Docker.
Week 2 - Domains, AWS, ER Diagrams and Databases, Understand the Pet Clinic Setup.
Week 3 - MVC, Repository interfaces, Thymeleaf, Internationalization, Controller Unit Tests,
Week 4 - Use Case Narrative, Sequence Diagram, State Chart Diagram, Create new objects.
Lesson 5 - Users, roles, and permissions setup from Java 2. Unit Tests. Show object details.
Lesson 6 - User Registration. Update and delete existing objects.
Lesson 7 - Homepage with content from the database. Custom not found and error pages.
Lesson 8 - Login and logout. Cookies. User permission access.
Lesson 9 - Edit and delete user profile. Password reset.
Lesson 10 - Filtering and limiting records by category.
Lesson 11 - Web Sockets. Date and currency formatting.
Lesson 12 - Email and SMS messaging. Failed login attempts.
Lesson 13 - Shopping Cart. Payment processing.
Sort and filter the list of schools
update and delete a school
language toggle
Use local storage to remember language toggle.
Access schools by visiting "/schools/kirkwood" instead of "/schools/1"
Cannot snapshot C:\Users\mlhau\OneDrive\Kirkwood\Grading\spring-athleagues-lp\build\resources\main\db\h2\data.sql: not a regular file
Cannot snapshot C:\Users\mlhau\OneDrive\Kirkwood\Grading\spring-athleagues-lp\build\classes\java\main\org\springframework\samples\petclinic\model\BaseEntity.class: not a regular file
Change /register to /register-student
Flash Message with Closeable X
<div th:if="${messageSuccess}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${messageSuccess}">Profile updated successfully.</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
The current set up is suitable for building a REST API that communicates with a JavaScript frontend like React, Angular, or Vue.
The Pet Clinic project uses an MVC structure with Thymeleaf to handle the frontend workload.
For this project, we need to refactor the AuthController to be a Spring MVC Controller that serves HTML forms and handles the domain redirection logic.
package org.springframework.samples.petclinic.user;
import jakarta.validation.Valid;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.school.SchoolRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Optional;
@Controller
public class AuthController {
private final UserService userService;
private final SchoolRepository schoolRepository;
public AuthController(UserService userService, SchoolRepository schoolRepository) {
this.userService = userService;
this.schoolRepository = schoolRepository;
}
@GetMapping("/register")
public String initRegisterForm(Model model) {
model.addAttribute("user", new User());
return "auth/registerForm";
}
@PostMapping("/register")
public String processRegisterForm(@Valid User user, BindingResult result) {
if (result.hasErrors()) {
return "auth/registerForm";
}
// 1. Save the User (UserService handles password hashing)
try {
userService.registerNewUser(user);
} catch (RuntimeException ex) {
// Handle duplicate email or other service errors
result.rejectValue("email", "duplicate", "This email is already registered");
return "auth/registerForm";
}
// 2. Parse Domain and Redirect
String email = user.getEmail();
String domain = email.substring(email.indexOf("@") + 1);
Optional<School> school = schoolRepository.findByDomain(domain);
if (school.isPresent()) {
return "redirect:/schools/" + school.get().getId();
} else {
// Fallback if no school matches the email domain
return "redirect:/";
}
}
@GetMapping("/login")
public String initLoginForm() {
return "auth/loginForm";
}
}Key Changes:
@Controller instead of @RestController (returns Views, not JSON).
initRegisterForm: Prepares the blank User object.
processRegisterForm: Handles validation, saves the user via UserService, parses the email domain, and redirects to the specific School page.
Your SecurityConfig.java is still pointing to the old API endpoints. We need to open the new /register URL so users can access it without logging in.
Replace the two existing .requestMatchers with this:
.requestMatchers("/register","/login").permitAll()
In SchoolRepository, add a method to look up a school by its domain.
Using Optional allows us to handle the "School Not Found" scenario (like when a user registers with @gmail.com) explicitly in the Controller using .isPresent() or .orElse(), preventing NullPointerExceptions.
package org.springframework.samples.petclinic.school;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.Optional;
public interface SchoolRepository extends Repository<School, Integer> {
/**
* Get a School by its domain.
*/
@Transactional(readOnly = true)
Optional<School> findByDomain(String domain);
/**
* Retrieve all Schools from the data store.
*/
@Transactional(readOnly = true)
Collection<School> findAll();
/**
* Retrieve Schools by page (for pagination in the UI)
*/
@Transactional(readOnly = true)
Page<School> findAll(Pageable pageable);
/**
* Save a School to the data store, either inserting or updating it.
*/
void save(School school);
/**
* Retrieve a School by its id.
*/
@Transactional(readOnly = true)
School findById(Integer id);
}
Create registerForm.html in a "src/main/resources/templates/auth/" folder.
This uses the same inputField fragment you previously updated to ensure consistent styling and validation messages.
th:replace="~{fragments/layout ...}": This means "I USE the layout." as defined in layout.html.
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'register')}">
<body>
<h2 th:text="#{title.register.new}">Register New Student</h2>
<form th:object="${user}" class="form-horizontal" method="post">
<input th:replace="~{fragments/inputField :: input ('firstName', 'First Name', 'text')}" />
<input th:replace="~{fragments/inputField :: input ('lastName', 'Last Name', 'text')}" />
<input th:replace="~{fragments/inputField :: input ('email', 'Email', 'text')}" />
<input th:replace="~{fragments/inputField :: input ('password', 'Password', 'password')}" />
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button class="btn btn-primary" type="submit" th:text="#{button.register}">Register</button>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<p>
<span th:text="#{text.auth.existing}">Already have an account?</span>
<a th:href="@{/login}" th:text="#{link.auth.login}">Login here</a>
</p>
</div>
</div>
</form>
</body>
</html>Add English translations.
You can also add these to your Spanish/Korean files if you wish, but it's not strictly required to pass unit tests.
# Auth / Registration
title.register.new=Register New Student
button.register=Register
text.auth.existing=Already have an account?
link.auth.login=Login hereOur inputField.html fragment currently has logic for 'text' and 'date', but it has no instruction for what to do if the type is 'password', so it will render nothing.
Open inputField.html and add a new th:case="'password'" to the switch statement.
<input th:case="'password'"
th:class="${#fields.hasErrors(name)} ? 'form-control is-invalid' : 'form-control'"
type="password" th:field="*{__${name}__}" />
We want to include a "Register" and "Login" button that changes to "Edit Profile" and "Logout" when the user is signed in?
To implement this, we need to use Spring Security Dialect for Thymeleaf (sec:authorize) to conditionally show buttons based on the user's login status.
First, ensure your layout.html (at the very top of the file) includes the security namespace, or the sec: tags will be ignored.
<html th:fragment="layout (template, menu)" xmlns:th="https://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
Thymeleaf does not understand sec:authorize out of the box. Without the specific plugin library, it ignores the attribute and renders the buttons by default.
Open build.gradle.
Add this line to your dependencies block:
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
Reload Gradle (Click the Elephant icon).
On desktop view, the Home and Find Schools links should be next to the branding logo, and the new buttons should be on the right.
On mobile view, the "Home" and "Find Schools" buttons should be stacked vertically with the new buttons underneath.
Place this code after the last ul tag in the nav section. It must go inside the div#main-navbar.
<ul class="navbar-nav ms-auto">
<li class="nav-item" sec:authorize="!isAuthenticated()">
<a class="nav-link" th:href="@{/register}"
th:classappend="${menu == 'register' ? 'active' : ''}">
<span class="fa fa-user-plus"></span> Register
</a>
</li>
<li class="nav-item" sec:authorize="!isAuthenticated()">
<a class="nav-link" th:href="@{/login}"
th:classappend="${menu == 'login' ? 'active' : ''}">
<span class="fa fa-sign-in"></span> Login
</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" th:href="@{/users/profile}"
th:classappend="${menu == 'profile' ? 'active' : ''}">
<span class="fa fa-user"></span> Edit Profile
</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<form th:action="@{/logout}" method="post" class="d-flex" style="height: 100%; margin: 0;">
<button type="submit" class="nav-link btn btn-link"
style="border: none; background: transparent; cursor: pointer; display: flex; align-items: center;">
<span class="fa fa-sign-out" style="margin-right: 5px;"></span> Logout
</button>
</form>
</li>
</ul>sec:authorize="!isAuthenticated()": Shows the content only if the user is Anonymous (Guest).
sec:authorize="isAuthenticated()": Shows the content only if the user is Logged In.
The Left <ul> has me-auto (Margin End Auto). This consumes all available empty space to its right, effectively pushing the next element (the Right <ul>) to the edge.
Logout Form: I wrapped the logout button in a <form method="post">.
th:classappend) to check if the variable menu (passed from the page) matches the specific name for that button. This styles the "active" link.Temporarily change the Logout button from "isAuthenticated()" to "!isAuthenticated()"
The Logout item is a <button> inside a <form>. Browsers apply a default "User Agent Stylesheet" to buttons (gray background, borders, padding) that overrides the Bootstrap .nav-link class.
Add this code at the bottom of the layout.html <head> section to force the button to behave like an <a> tag and inherit the parent's hover effects.
<style>
.navbar-nav button.nav-link {
border: none;
border-radius: 0;
cursor: pointer;
padding: 28px 8px;
display: flex;
align-items: center;
text-transform: uppercase;
font-size: 14px;
font-family: 'montserratregular', sans-serif;
}
@media (max-width: 991.98px) {
.navbar-nav form {
width: 100%;
}
.navbar-nav button.nav-link {
padding: 28px 20px;
width: 100%;
justify-content: flex-start; /* Moves icon/text to the left */
text-align: left; /* Fallback for older browsers */
}
}
</style>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script>
$(document).ready(function(){
$('.navbar-nav button.nav-link').hover(
function() {
// Code to execute on mouse enter (function_in)
$(this).css("background-color", "#6db33f");
},
function() {
// Code to execute on mouse leave (function_out)
$(this).css("background-color", "rgba(0, 0, 0, 0)");
}
);
});
</script>From Java 2, our User.java has Database rules (nullable = false), which means the database schema requires them. We changed the structure to not require them.
User.java is also missing Java Bean Validation rules (@NotEmpty). The Controller checks the Java rules, not the Database rules.
You need to import jakarta.validation.constraints.* and add the annotations to the fields.
The @Valid annotation in the AuthController looks for these tags.
package org.springframework.samples.petclinic.user;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import java.util.Set;
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name="first_name", nullable = true, length = 50)
private String firstName;
@Column(name="last_name", nullable = true, length = 50)
private String lastName;
@Column(nullable = false, unique = true, length = 100)
@NotEmpty(message = "Email is required") // Stops empty strings
@Email(message = "Please enter a valid email") // Enforces email format
private String email;
@Column(name="password_hash", nullable = false, length = 255)
@NotEmpty(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$", message = "Password must contain uppercase, lowercase, and number")
private String password;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
@EqualsAndHashCode.Exclude
private Set<Role> roles;
}Run the app, click Register, and sign up as yourname.student@kirkwood.edu. It should redirect you to the Kirkwood school page.
Sign up as yourname.student@uiowa.edu. It should redirect you to the Iowa school page.
Sign up as yourname.student@example.edu. It should redirect you to the Iowa school page.
Some schools have different types of emails for students or alumni, like "@student.kirkwood.edu" or "@alumni.uni.edu".
Update AuthController.java to add a helper method called findSchoolByRecursiveDomain that will loop through the domain parts until it finds a match or runs out of parts.
Update part 2 of the processRegisterForm method to call that method.
package org.springframework.samples.petclinic.user;
import jakarta.validation.Valid;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.school.SchoolRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Optional;
@Controller
public class AuthController {
private final UserService userService;
private final SchoolRepository schoolRepository;
public AuthController(UserService userService, SchoolRepository schoolRepository) {
this.userService = userService;
this.schoolRepository = schoolRepository;
}
@GetMapping("/register")
public String initRegisterForm(Model model) {
model.addAttribute("user", new User());
return "auth/registerForm";
}
@PostMapping("/register")
public String processRegisterForm(@Valid User user, BindingResult result) {
if (result.hasErrors()) {
return "auth/registerForm";
}
// 1. Save the User (UserService handles password hashing)
try {
userService.registerNewUser(user);
} catch (RuntimeException ex) {
// Handle duplicate email or other service errors
result.rejectValue("email", "duplicate", "This email is already registered");
return "auth/registerForm";
}
// 2. Parse Domain and Redirect
String email = user.getEmail();
Optional<School> school = findSchoolByRecursiveDomain(email);
if (school.isPresent()) {
return "redirect:/schools/" + school.get().getId();
} else {
// Fallback if no school matches the email domain
return "redirect:/";
}
}
@GetMapping("/login")
public String initLoginForm() {
return "auth/loginForm";
}
private Optional<School> findSchoolByRecursiveDomain(String email) {
// 1. Extract the initial domain (e.g., "student.kirkwood.edu")
String domain = email.substring(email.indexOf("@") + 1);
// 2. Loop while the domain is valid (has at least one dot)
while (domain.contains(".")) {
// 3. Check Database
Optional<School> school = schoolRepository.findByDomain(domain);
if (school.isPresent()) {
return school; // Found match (e.g., "kirkwood.edu")
}
// 4. Strip the first part (e.g., "student.kirkwood.edu" -> "kirkwood.edu")
int dotIndex = domain.indexOf(".");
domain = domain.substring(dotIndex + 1);
}
return Optional.empty();
}
}
You should verify this works by creating AuthControllerTest.java with a unit test that processes a user register with subdomain redirect.
package org.springframework.samples.petclinic.user;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledInNativeImage;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.school.SchoolRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.aot.DisabledInAotMode;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@WebMvcTest(AuthController.class)
@DisabledInNativeImage
@DisabledInAotMode
class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private SchoolRepository schoolRepository;
@MockitoBean
private UserService userService;
@MockitoBean
private AuthenticationManager authenticationManager;
@Test
void testProcessRegister_WithSubdomainRedirect() throws Exception {
// Mock: School exists for "kirkwood.edu"
School kirkwood = new School();
kirkwood.setId(1);
kirkwood.setName("Kirkwood");
kirkwood.setDomain("kirkwood.edu");
// Repository only knows "kirkwood.edu"
given(schoolRepository.findByDomain("kirkwood.edu")).willReturn(Optional.of(kirkwood));
// Repository does NOT know "student.kirkwood.edu"
given(schoolRepository.findByDomain("student.kirkwood.edu")).willReturn(Optional.empty());
given(userService.registerNewUser(any(User.class))).willReturn(new User());
// MOCK THE LOGIN-When the controller asks to authenticate, return a dummy "Success" token
given(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
.willReturn(new TestingAuthenticationToken("user", "password", "ROLE_STUDENT"));
// User registers with SUBDOMAIN
mockMvc.perform(post("/register")
.with(csrf())
.param("email", "alex@student.kirkwood.edu") // <--- Subdomain input
.param("password", "StrongPass1!"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/schools/1")); // Should still find ID 1
}
}
To implement a "Flash Message" (a temporary notification that survives a redirect), we use Spring's RedirectAttributes.
Step 1: Update AuthController.java
Update the processRegisterForm method to accept RedirectAttributes. This allows us to pass data (the message) that survives the browser redirect.
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class AuthController {
@PostMapping("/register")
public String processRegisterForm(@Valid User user,
BindingResult result,
RedirectAttributes redirectAttributes) { // <--- Inject RedirectAttributes
// code omitted
if (school.isPresent()) {
// 1. Set the Success Message
redirectAttributes.addFlashAttribute("messageSuccess",
"Your user account is created. You have been redirected to " + school.get().getName() + "'s school page.");
return "redirect:/schools/" + school.get().getId();
} else {
// 1b. Set a Generic Message (Warn them since they didn't match a school)
redirectAttributes.addFlashAttribute("messageWarning",
"Your user account is created, but we could not find a school matching your email domain.");
// Fallback if no school matches the email domain
return "redirect:/";
}
}
}Step 2: Update layout.html HTML
We need to add a "Slot" for this message to appear. The best place is inside the main container, right above where the page content (th:replace="${template}") is injected.
It defines three distinct blocks. Since they all use the class flash-message, a jQuery script can be written to automatically handle the slide-up animation for any of them.
</nav>
<div class="container-fluid">
<div class="container xd-container">
<div th:if="${messageSuccess}"
class="alert alert-success alert-dismissible fade show flash-message"
role="alert">
<i class="fa fa-check-circle"></i>
<span th:text="${messageSuccess}">Success</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div th:if="${messageWarning}"
class="alert alert-warning alert-dismissible fade show flash-message"
role="alert">
<i class="fa fa-exclamation-circle"></i>
<span th:text="${messageWarning}">Warning</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div th:if="${messageDanger}"
class="alert alert-danger alert-dismissible fade show flash-message"
role="alert">
<i class="fa fa-times-circle"></i>
<span th:text="${messageDanger}">Error</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<th:block th:insert="${template}" />Step 3: Update layout.html JavaScript
To make it disappear automatically after 10 seconds, add this script.
<script>
$(document).ready(function(){
$('.navbar-nav button.nav-link').hover(
function() {
// Code to execute on mouse enter (function_in)
$(this).css("background-color", "#6db33f");
},
function() {
// Code to execute on mouse leave (function_out)
$(this).css("background-color", "rgba(0, 0, 0, 0)");
}
);
const $flash = $('.flash-message');
if ($flash.length) {
setTimeout(function() {
// slideUp creates a smooth "disappearing" animation
$flash.slideUp(500, function() {
// Once animation is done, remove from DOM
$(this).remove();
});
}, 10000);
}
});
</script>Spring Security creates session cookies automatically to track when a user is "logged in".
The missing piece is that right now, you are saving the user to the database, but you aren't logging them in. The user remains "Anonymous," so isAuthenticated() returns false, and your buttons don't change.
Your UserService.registerNewUser(user) method takes the password (e.g., "password123") and overwrites it with a Bcrypt hash (e.g., $2a$10$EixZa...).
To log the user in, we need the raw password. If we try to login with the hash, it will fail.
Open AuthController.java
We will modify processRegisterForm to:
Capture the raw password before the Service hashes it.
Inject HttpServletRequest to access the login functionality.
Log in the user using AuthenticationManager.
// Add these imports
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.ServletException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
@Controller
public class AuthController {
private final UserService userService;
private final SchoolRepository schoolRepository;
private final AuthenticationManager authenticationManager; // Add this field
// Add to Constructor
public AuthController(UserService userService, SchoolRepository schoolRepository, AuthenticationManager authenticationManager) {
this.userService = userService;
this.schoolRepository = schoolRepository;
this.authenticationManager = authenticationManager;
}
@PostMapping("/register")
public String processRegisterForm(@Valid User user,
BindingResult result,
RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
return "auth/registerForm";
}
String rawPassword = user.getPassword();
// 1. Save the User (UserService handles password hashing)
try {
userService.registerNewUser(user);
} catch (RuntimeException ex) {
// Handle duplicate email or other service errors
result.rejectValue("email", "duplicate", "This email is already registered");
return "auth/registerForm";
}
// 2. LOGIN using the authenticationManager.
try {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(user.getEmail(), rawPassword);
Authentication authentication = authenticationManager.authenticate(authToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
redirectAttributes.addFlashAttribute("messageDanger", "Account created, but auto-login failed.");
return "redirect:/login";
}
// 3. REDIRECT
Optional<School> school = findSchoolByRecursiveDomain(user.getEmail());
// existing code omitted
}
}How it works
request.login(email, password): This is a standard Jakarta EE method. It asks Spring Security to verify the credentials.
Success: If valid, Spring Security creates a session, generates a JSESSIONID cookie, sends it to the browser, and flips the user status to isAuthenticated().
Redirect: When the browser loads the next page (the School page), it sends the cookie back.
Layout: Your layout.html sees isAuthenticated() is true and renders the Edit Profile / Logout buttons.
Restart the application. Register a new user.
Upon redirect, look at the top right. You should immediately see EDIT PROFILE and LOGOUT.
If you restart the server and visit "/schools/new" and submit the form with no input, you'll get a 403 forbidden error.
This error is happening because in your SecurityConfig.java, you likely have this line which protects POST requests, but allows GET requests.
.requestMatchers(HttpMethod.GET).permitAll()
This allows an anonymous user to view the form (GET /schools/new).
However, you do not have a matching rule for the POST request. Therefore, it falls through to your catch-all rule:
.anyRequest().authenticated()
Because we disabled the default login form redirect (.formLogin(AbstractHttpConfigurer::disable)), Spring Security simply blocks the request with a 403 Forbidden instead of redirecting you to a login page.
If you want to test the form validation without logging in, you must explicitly allow POST requests to that URL.
Open SecurityConfig.java and add the specific matcher:
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.GET).permitAll()
.requestMatchers("/register", "/login").permitAll()
// TEMPORARY: Allow anonymous users to POST to these forms
.requestMatchers(HttpMethod.POST, "/schools/new", "/owners/new").permitAll()
.anyRequest().authenticated()
)However, since we have built the Login/Register feature, the intended flow is that only logged-in users should be able to create schools or owners.
If you want to test the form validation without logging in, you must explicitly allow POST requests to that URL.
Open SecurityConfig.java and add the specific matcher:
If you want to test the form validation without logging in, you must explicitly allow POST requests to that URL.
Open SecurityConfig.java and add the specific matcher:
When a user registers, they are redirected to their school's page, but the Register/Login buttons don't change to Edit Profile/Logout.
The issue we are experiencing happens because of a change in recent versions of Spring Security (specifically Spring Security 6 / Spring Boot 3).
Currently, our code successfully authenticates the user in the AuthController, but it forgets the user as soon as the redirect happens.
In the newest version of Spring Security, manually setting the authentication in the SecurityContextHolder no longer automatically saves that context into the user's HTTP Session. You have to explicitly tell Spring to save it so it survives the redirect.
When a user registers, they are redirected to their school's page, but the Register/Login buttons don't change to Edit Profile/Logout.
The issue we are experiencing happens because of a change in recent versions of Spring Security (specifically Spring Security 6 / Spring Boot 3).
Currently, our code successfully authenticates the user in the AuthController, but it forgets the user as soon as the redirect happens.
In the newest version of Spring Security, manually setting the authentication in the SecurityContextHolder no longer automatically saves that context into the user's HTTP Session. You have to explicitly tell Spring to save it so it survives the redirect.
Step 1: Update your processRegisterForm method signature to include HttpServletRequest request.
@PostMapping("/register-student")
public String processRegisterForm(@Valid User user,
BindingResult result,
RedirectAttributes redirectAttributes,
HttpServletRequest request) {
Step 2: Add the session-saving logic immediately after setting the authentication. This ensures the authentication survives the redirect
HttpSession session = request.getSession(true);
session.setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext()
);
On the /register-student page, if I enter an email that already exists in the database, the error "is already in use" displays, but I want the message in this line of code to display.
result.rejectValue("email", "duplicate", "This email is already registered");
In the standard PetClinic template, the word duplicate is already defined in the messages.properties file, and its value is set to "is already in use".
When you call rejectValue("email", "duplicate", "..."), Spring sees the "duplicate" error code, looks it up in the properties file, and uses it.
If you change the error code (the second argument) to something that doesn't exist in the properties file (like "duplicateEmail"), Spring will be forced to use your hardcoded default message.
If you plan to keep doing translations, a better practice would be to update messages.properties
Spring builds a hierarchy of error codes when validation fails. You can target this exact field by adding a highly specific key to the bottom of your messages.properties file:
duplicate.user.email=This email is already registered
If you do this, you do not need to change your Java code at all. Spring will look up duplicate.user.email, find your new custom sentence, and display it on the form.
If you created a custom @Unique annotation for the User class—similar to what we did for the School domain earlier—that validation might be triggering during the @Valid check before it even reaches this line of code. If so, you would update the message attribute inside that annotation instead
GitHub Actions works as your "robot butler." Instead of you manually running gradle build, docker build, and docker push on your laptop, GitHub does it automatically every time you push code.
Here is the workflow:
You push code to your GitHub repository.
GitHub Actions wakes up, spins up a temporary server (runner).
It builds your jar using Gradle.
It logs in to Docker Hub (using secrets you provide).
It builds and pushes your Docker image.
You should never put your Docker Hub password directly in the file. You use GitHub Secrets.
Go to your GitHub repository.
Click Settings -> Secrets and variables -> Actions.
Click New repository secret.
Add these two:
Name: DOCKERHUB_USERNAME
Secret Value: your_actual_username
Name: DOCKERHUB_TOKEN
Secret Value: (Go to Docker Hub -> Account Settings -> Personal access tokens -> Generate New Token)
For access permissions, select "Read & Write".
Use this token instead of your real password.
In your project, create this directory structure and file: .github/workflows/deploy.yml
Paste this content in, customized for Gradle and Java 17.
Change petclinic to your image name.
To keep your v1, v2, v3 pattern going without having to manually type it every time, you can use a built-in GitHub variable called github.run_number.
name: Build and Push Docker Image
on:
push:
branches: [ "main" ]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
# 1. Download your code
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Java 17 (Same as your local environment)
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'microsoft' # or 'temurin' if you prefer
# 3. Build the JAR file with Gradle
# We skip tests here to speed it up, but you can remove '-x test' to be safer
- name: Build with Gradle
run: ./gradlew clean bootJar -x test -x jar
# 4. Set up Docker Buildx (required for modern Docker builds)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 5. Log in to Docker Hub
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 6. Build and Push the Docker image
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/petclinic:v${{ github.run_number }}
The petclinic app comes with three workflows `deploy-and-test-cluster.yml`, `gradle-build.yml`, and `maven-build.yml`.
Here is exactly why it is safe to remove them:
maven-build.yml: You already committed to Gradle for this project, making the Maven pipeline completely redundant.
gradle-build.yml: By default, this workflow runs ./gradlew build, which automatically executes all of the Spring Boot integration tests. Because you updated the application properties to require environment variables for your external MySQL database, the GitHub Actions server cannot start the application context to run the tests, which will cause the entire run to crash. Furthermore, your custom deploy.yml file already handles the necessary Gradle building for you (while intentionally skipping those tests using the -x test flag).
deploy-and-test-cluster.yml: This is a highly specialized workflow built for deploying the application to a Kubernetes cluster (specifically using a tool called standard kind). Since you are using Azure Container Apps, this file serves no purpose for your infrastructure.
Commit this file and push it to GitHub.
git add .
git commit -m "Add GitHub Action workflow"
git push
Go to the "Actions" tab in your GitHub repository. You will see the workflow running. When it turns green, check Docker Hub—your image will have been freshly updated!
By doing this, every single push to your main branch will create a brand-new, uniquely numbered tag (starting with v1).
Right now, GitHub pushes the image, but Azure doesn't know about it yet. You have two choices:
Continuous Deployment (CD) in Azure:
Go to your Azure Container App in the portal.
Under Application -> Revision management (or Continuous deployment), you can enable a setting that says "Create a new revision when a new image is pushed."
Note: This often requires a webhook setup.
Add an Azure Step to the YAML:
You can add a final step to the GitHub Action that runs az containerapp update to tell Azure to pull the new image immediately.
We will set this up on the next slide.
To make this work, GitHub Actions needs an "ID badge" (a Service Principal) so Azure trusts it enough to accept the remote update command.
Run this command in your PowerShell terminal to create a dedicated service account that only has access to your specific resource group, keeping your wider Azure account secure.
az ad sp create-for-rbac `
--name "github-actions-petclinic" `
--role contributor `
--scopes /subscriptions/your-subscription-id/resourceGroups/your-resource-group-rg `
--json-auth
Replace with your subscription id and resource group
Go to Azure, open your resource group, click Access control (IAM), then Role Assignments.
Go back to your GitHub repository.
Navigate to Settings -> Secrets and variables -> Actions.
Click New repository secret.
Name: AZURE_CREDENTIALS
Value: (Paste the entire JSON block here)
Click Add secret.
Add these final two steps to the very bottom of your .github/workflows/deploy.yml file.
Since your GitHub Actions workflow runs on a Linux machine (ubuntu-latest), we use the bash line-continuation character \ here instead of the PowerShell backtick.
Change petclinic to your image name.
# 7. Log in to Azure
- name: Log in to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# 8. Tell Azure to pull the new image
- name: Update Azure Container App
run: |
az config set extension.use_dynamic_install=yes_without_prompt
az containerapp update \
--name petclinic-app \
--resource-group petclinic-rg \
--image ${{ secrets.DOCKERHUB_USERNAME }}/petclinic:v${{ github.run_number }}To securely run your automated tests against your external MySQL database, we need to inject those credentials into the GitHub Actions runner right before Gradle executes.
Go to your GitHub repository.
Navigate to Settings -> Secrets and variables -> Actions.
Click New repository secret and add these three secrets one by one:
Name: MYSQL_URL, Value: jdbc:mysql://YOUR_WEB_DB_HOST:3306/YOUR_DB_NAME?useSSL=true
Name: MYSQL_USER, Value: YOUR_DB_USERNAME
Name: MYSQL_PASS, Value: YOUR_DB_PASSWORD
Now, we need to update Step 3 in your .github/workflows/deploy.yml file.
Provide the secrets to the step using the env: block.
Change the run command to ./gradlew clean build -x jar. (Using build instead of bootJar tells Gradle to explicitly run the test phase before packaging the application).
If someone submits a pull request or pushes broken code to your repository, the test phase will fail. When a step fails in GitHub Actions, the entire workflow immediately stops. This guarantees that a broken image is never built, pushed to Docker Hub, or deployed to Azure.
# 3. Build and Test the application with Gradle
- name: Build and Test with Gradle
env:
MYSQL_URL: ${{ secrets.MYSQL_URL }}
MYSQL_USER: ${{ secrets.MYSQL_USER }}
MYSQL_PASS: ${{ secrets.MYSQL_PASS }}
run: ./gradlew clean build -x jarThe project uses the Spring Java Format plugin. This is a strict code-styling enforcer that the Spring team uses to guarantee all code looks exactly the same (spacing, indentation, bracket placement, etc.).
When your GitHub Action runs./gradlew clean build, it triggers the checkFormatMain task. The plugin scans new Java files you add and fails if they don't perfectly match the official Spring style guide.
You do not need to format these files by hand. The Gradle plugin can fix them for you automatically.
Open the terminal in IntelliJ and run the automatic formatter command:
Windows (PowerShell): .\gradlew format
Mac/Linux: ./gradlew format
The Spring PetClinic project includes a plugin called NoHttp. Its sole purpose is to scan every single file in your project (Java, HTML, XML, Markdown, etc.) and fail the build if it finds a URL that starts with http:// instead of the secure https://.
This guarantees that the application never accidentally loads resources over an unencrypted, insecure connection.
Open the file mentioned in the error: src\main\resources\templates\fragments\layout.html
Go to line 3.
Change the http to https so it looks exactly like this: https://www.thymeleaf.org/extras/spring-security
Commit and push this updated YAML file to your main branch. This will immediately trigger the workflow.
If successful, GitHub will build your app, push the dynamically tagged image to Docker Hub, and seamlessly tell Azure to swap to that exact numbered version!
If it failed, GitHub provides detailed logs for every single command it runs.
Click on the workflow run that has a red X next to it.
On the left side under "Jobs", click build-and-push.
You will see a list of steps. Click on the step that has the red X (likely Build and Test with Gradle) to expand its terminal output.
Scroll to the bottom of that expanded log. You are looking for a specific Java or Gradle error.
Our SiteGround databases currently allow access from any IP address, which is not secure.
If I limited the IP addresses, while your laptop might be allowed to connect, the GitHub Actions server is a random machine in the cloud. SiteGround will look at GitHub's IP address and block the connection, causing your Gradle tests to hang and fail.
If it is a firewall issue, you generally have two paths forward:
Option A (Skip Tests in CI): The easiest fix. You can change the YAML run command back to ./gradlew clean bootJar -x test -x jar. You simply rely on running your tests locally on your computer before you push.
Option B (Testcontainers): The professional standard. Instead of connecting to your external SiteGround database during the GitHub test phase, you configure Spring Boot to spin up a temporary, throwaway Docker container with MySQL inside the GitHub runner itself just for the tests.
DROP TABLE IF EXISTS leagues;
CREATE TABLE leagues (
id INT AUTO_INCREMENT PRIMARY KEY,
school_id INT NOT NULL,
location_id INT, -- Default location
user_id INT, -- League Manager/Contact
name VARCHAR(255) NOT NULL,
description TEXT,
registration_start DATETIME,
registration_end DATETIME,
league_start DATETIME,
league_end DATETIME,
is_public TINYINT DEFAULT 1,
type ENUM('male', 'female', 'coed') NOT NULL,
capacity INT,
capacity_type ENUM('team', 'individual') NOT NULL,
fee DECIMAL(6,2),
status_id ENUM('draft', 'active', 'inactive', 'postponed', 'cancelled', 'past') DEFAULT 'draft',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_leagues_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
CONSTRAINT fk_leagues_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
CONSTRAINT fk_leagues_manager FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
-- Performance: Quickly find active leagues for a specific school
INDEX idx_leagues_school_status (school_id, status_id)
);
DROP TABLE IF EXISTS teams;
CREATE TABLE teams (
id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NOT NULL,
captain_user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
logo_url VARCHAR(255),
status_id ENUM('active', 'inactive', 'suspended') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_teams_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
CONSTRAINT fk_teams_captain FOREIGN KEY (captain_user_id) REFERENCES users(id) ON DELETE CASCADE,
-- Performance: List all teams in a league
INDEX idx_teams_league (league_id)
);
DROP TABLE IF EXISTS team_users;
CREATE TABLE team_users (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
user_id INT NOT NULL,
role ENUM('member', 'captain') DEFAULT 'member',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_team_users_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_team_users_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- Integrity: A user cannot join the same team twice
UNIQUE INDEX idx_team_users_unique (team_id, user_id)
);DROP TABLE IF EXISTS events;
CREATE TABLE events (
id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NOT NULL,
location_id INT,
user_id INT, -- Event organizer/referee
name VARCHAR(255) NOT NULL,
description TEXT,
event_start DATETIME,
event_end DATETIME,
status_id ENUM('draft', 'active', 'ongoing', 'postponed', 'cancelled', 'final') DEFAULT 'draft',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_events_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
CONSTRAINT fk_events_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
CONSTRAINT fk_events_contact FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
-- Integrity & Performance: Check for double-bookings at a location
INDEX idx_events_location_time (location_id, event_start)
);
DROP TABLE IF EXISTS matches;
CREATE TABLE matches (
id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
home_team_id INT,
away_team_id INT,
winner_team_id INT,
home_score INT DEFAULT 0,
away_score INT DEFAULT 0,
status ENUM('scheduled', 'in_progress', 'final', 'forfeit') DEFAULT 'scheduled',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_matches_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
CONSTRAINT fk_matches_home FOREIGN KEY (home_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_matches_away FOREIGN KEY (away_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_matches_winner FOREIGN KEY (winner_team_id) REFERENCES teams(id) ON DELETE SET NULL,
-- Performance: Essential for calculating standings (W/L records)
INDEX idx_matches_home (home_team_id),
INDEX idx_matches_away (away_team_id)
);DROP TABLE IF EXISTS messages;
CREATE TABLE messages (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id_from INT NOT NULL,
user_id_to INT,
league_id INT,
event_id INT,
parent_message_id INT, -- For threaded replies
message VARCHAR(255) NOT NULL,
is_flagged TINYINT DEFAULT 0,
status_id ENUM('draft', 'active', 'hidden') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_messages_from FOREIGN KEY (user_id_from) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_to FOREIGN KEY (user_id_to) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_messages_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_parent FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE,
-- Performance: Quickly load chat history for a league or event
INDEX idx_messages_context (league_id, event_id)
);
DROP TABLE IF EXISTS message_reactions;
CREATE TABLE message_reactions (
id INT AUTO_INCREMENT PRIMARY KEY,
message_id INT NOT NULL,
user_id INT NOT NULL,
reaction ENUM('like', 'dislike', 'love', 'hug', 'sad', 'angry'),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_reactions_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
CONSTRAINT fk_reactions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- Integrity: One reaction per user per message
UNIQUE INDEX idx_reactions_unique (message_id, user_id)
);It is possible to replace Thymeleaf with a frontend JavaScript framework like React, Angular, or Vue.
This involves shifting your application from a Server-Side Rendered (SSR) architecture to a Client-Side Rendered (CSR) or Single Page Application (SPA) architecture.
Here is how that transition works:
Thymeleaf: The Spring Boot server generates the full HTML page. It merges the data (e.g., a list of Vets) with the template (vetList.html) and sends the finished HTML to the browser.
React/Angular/Vue: The Spring Boot server becomes a REST API. It sends only the raw data (usually in JSON format). The JavaScript framework running in the browser receives that JSON and builds the HTML dynamically.
A benefit of the layered architecture (Controller > Service > Repository) is that you do not need to change your Database or Repository layers—you only need to modify the Controller layer.
If you look at VetController.java, the application already has an endpoint ready for a JavaScript framework to use:
// This method is for Thymeleaf (Returns a View)
@GetMapping("/vets.html")
public String showVetList(...) { ... }
// This method is for External Clients/JS Frameworks (Returns JSON/XML)
@GetMapping({ "/vets" })
public @ResponseBody Vets showResourcesVetList() {
Vets vets = new Vets();
vets.getVetList().addAll(this.vetRepository.findAll());
return vets;
}The @ResponseBody annotation tells Spring: "Do not look for a Thymeleaf template. Just take this Java object, convert it to JSON, and send it back."
A React or Angular app would make a fetch('/vets') call to this URL, receive the list of doctors, and render the table itself.
To fully replace Thymeleaf, you would:
Update Controllers: Change your @Controller classes to @RestController (which automatically applies @ResponseBody to every method).
Return Data, Not Strings: Instead of returning strings like "owners/createOrUpdateOwnerForm", your methods would return Owner objects or ResponseEntity objects.
Delete Templates: You would eventually delete the src/main/resources/templates folder since the Java app no longer generates HTML.
Frontend Build: You would build your React/Angular app separately. You can then either run it on a separate server (like Node.js) that talks to your Spring Boot API, or package the built JavaScript files into the Spring Boot static folder to serve them together.
By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.