The final session adds roles so that an admin sees pages a student cannot, shows who is logged in, and turns on CSRF protection so that forms cannot be submitted from another site.
Objectives
Do not copy. Read for understanding and the viva- Complete questions 41 to 46 of the manual: bootstrap, role based authentication and csrf
- Prepare the deliverable before the lab and finish it during the session
- Be ready to explain every step in the viva
Questions Covered
Do not copy. Read for understanding and the viva| Question | Requirement | Status |
|---|---|---|
| Q41 | Add Bootstrap styling in the Login and registration page in the above exercises in… | Complete |
| Q42 | Create a Role Table in the database and configure it with Spring Boot, Security and… | Complete |
| Q43 | Write code for role-based authentication using Spring Security | Complete |
| Q44 | Display current logged in User details on the dashboard along with client IP, data… | Complete |
| Q45 | Add CSRF functionality in the authentication | Complete |
| Q46 | Restrict role-based access to views in Spring Boot Security | Complete |
Preparation
Do not copy. Read for understanding and the viva- Role table with a many-to-many link to User; load roles as
GrantedAuthorityinUserDetailsService. - Restrict URLs with
.requestMatchers("/admin/**").hasRole("ADMIN")and views withsec:authorize(Thymeleaf) or<sec:authorize>(JSP). - CSRF is on by default in Spring Security 6; forms must carry the token (
th:actionadds it automatically, plain HTML forms need a hidden_csrfinput).
This is the last set of changes to admission-api. Files marked “replaces” overwrite the Session 9 version. Two accounts exist after the seeder runs: admin / admin123 with ROLE_ADMIN and ROLE_STUDENT, and asha / asha123 with ROLE_STUDENT only. Nothing was executed here; every listing and expected page was checked by reading against Spring Security 6.3 and Bootstrap 5.3.
Question 41
Problem Statement
Write in lab recordAdd Bootstrap styling in the Login and registration page in the above exercises in session 9.
Solution
Write in lab recordSteps
- Replace
templates/login.htmlandtemplates/register.htmlwith the listings. Both load Bootstrap 5.3 from the jsDelivr CDN with one<link>tag; no JavaScript bundle is needed for forms and cards. - Offline alternative (the manual’s WebJars route): add
org.webjars:bootstrap:5.3.3andorg.webjars:webjars-locator-coretopom.xml, then link/webjars/bootstrap/css/bootstrap.min.css. Add/webjars/**to thepermitAll()list. - Restart and open
/loginand/register. Resize the window; the card stays centred and shrinks to full width below 768 px. - Submit an invalid registration; the invalid fields get a red border and the message appears below the field.
Program
Lab record: every tab is one file of the answer. Write all of them.
<!DOCTYPE html>
<!-- src/main/resources/templates/login.html (Session 10: Q41 Bootstrap, Q45 explicit CSRF field) -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admission Portal - Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container">
<div class="row justify-content-center mt-5">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<h4 class="card-title text-center mb-3">Student Admission Portal</h4>
<div th:if="${param.error}" class="alert alert-danger py-2">Invalid username or password.</div>
<div th:if="${param.logout}" class="alert alert-success py-2">You have been logged out.</div>
<!-- Plain action (not th:action) so the CSRF token must be added by hand: Q45 -->
<form action="/login" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Sign in</button>
</form>
<p class="text-center mt-3 mb-0">New student? <a th:href="@{/register}">Register</a></p>
</div>
</div>
</div>
</div>
</div>
</body>
</html><!DOCTYPE html>
<!-- src/main/resources/templates/register.html (Session 10, Q41 Bootstrap; th:action adds the CSRF token) -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admission Portal - Register</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container">
<div class="row justify-content-center mt-5">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-body">
<h4 class="card-title text-center mb-3">Create your account</h4>
<form th:action="@{/register}" th:object="${form}" method="post" novalidate>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" th:field="*{username}"
th:classappend="${#fields.hasErrors('username')} ? 'is-invalid'" class="form-control">
<div class="invalid-feedback" th:errors="*{username}"></div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" th:field="*{password}"
th:classappend="${#fields.hasErrors('password')} ? 'is-invalid'" class="form-control">
<div class="invalid-feedback" th:errors="*{password}"></div>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm password</label>
<input type="password" th:field="*{confirmPassword}"
th:classappend="${#fields.hasErrors('confirmPassword')} ? 'is-invalid'" class="form-control">
<div class="invalid-feedback" th:errors="*{confirmPassword}"></div>
</div>
<button type="submit" class="btn btn-success w-100">Register and sign in</button>
</form>
<p class="text-center mt-3 mb-0">Already registered? <a th:href="@{/login}">Login</a></p>
</div>
</div>
</div>
</div>
</div>
</body>
</html>Output
Checked by reading. /login: light grey page, a white card about a third of the width, centred, with the title “Student Admission Portal”, two labelled inputs with rounded borders, a full-width blue “Sign in” button and a centred “New student? Register” line. After a wrong password a red alert “Invalid username or password.” appears above the form; after logout a green alert “You have been logged out.”
/register: same layout, slightly wider card, green “Register and sign in” button. On a failed submit each offending input turns red-bordered (is-invalid) and its message shows in red under it, for example “Username must be 4 to 50 characters”.
Explanation
Bootstrap is only CSS classes, so the Thymeleaf attributes from Session 9 are untouched. The layout is the standard grid: container, row justify-content-center, col-md-4 (four of twelve columns on medium screens and up, full width below). form-control styles the inputs and form-label the labels. Validation display uses two Bootstrap conventions together: th:classappend adds is-invalid to the input when #fields.hasErrors is true, and the sibling div.invalid-feedback is shown by Bootstrap only when the previous input has is-invalid, so no extra JavaScript is required. novalidate on the register form turns off the browser’s own popups so the server-side messages are the ones the examiner sees.
Question 42
Problem Statement
Write in lab recordCreate a Role Table in the database and configure it with Spring Boot, Security and Hibernate.
Solution
Write in lab recordSteps
- Add
Roleentity andRoleRepository. - Replace
User.javawith the version that has the@ManyToManyrolesset. - Replace
DataSeeder.javaso it creates the two roles and two users, andAppUserDetailsService.javaso it turns the roles into authorities. - Drop the old
usersrows once (DELETE FROM users;) so the seeder recreatesadminwith roles, or add the link rows by hand. - Restart; check with the
SELECTat the end ofroles.sql.
Program
Lab record: every tab is one file of the answer. Write all of them.
// src/main/java/in/ignou/admission/entity/Role.java (Session 10, Q42)
package in.ignou.admission.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/** Name carries the ROLE_ prefix so it maps 1:1 to a GrantedAuthority string. */
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 30)
private String name;
public Role() { }
public Role(String name) { this.name = name; }
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}// src/main/java/in/ignou/admission/repository/RoleRepository.java (Session 10, Q42)
package in.ignou.admission.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import in.ignou.admission.entity.Role;
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(String name);
}// src/main/java/in/ignou/admission/entity/User.java (Session 10, Q42: + roles)
package in.ignou.admission.entity;
import java.util.HashSet;
import java.util.Set;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, length = 100)
private String password;
private boolean enabled = true;
/**
* EAGER: the roles are needed on every login and the set is tiny.
* The join table user_roles(user_id, role_id) is created by Hibernate.
*/
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles = new HashSet<>();
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public Set<Role> getRoles() { return roles; }
public void setRoles(Set<Role> roles) { this.roles = roles; }
}// src/main/java/in/ignou/admission/security/DataSeeder.java (Session 10, Q42: roles + one user per role)
package in.ignou.admission.security;
import java.util.Set;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import in.ignou.admission.entity.Role;
import in.ignou.admission.entity.User;
import in.ignou.admission.repository.RoleRepository;
import in.ignou.admission.repository.UserRepository;
@Component
public class DataSeeder implements CommandLineRunner {
private final UserRepository users;
private final RoleRepository roles;
private final PasswordEncoder encoder;
public DataSeeder(UserRepository users, RoleRepository roles, PasswordEncoder encoder) {
this.users = users;
this.roles = roles;
this.encoder = encoder;
}
@Override
public void run(String... args) {
Role admin = role("ROLE_ADMIN");
Role student = role("ROLE_STUDENT");
user("admin", "admin123", Set.of(admin, student));
user("asha", "asha123", Set.of(student));
}
private Role role(String name) {
return roles.findByName(name).orElseGet(() -> roles.save(new Role(name)));
}
private void user(String username, String rawPassword, Set<Role> userRoles) {
if (users.existsByUsername(username)) {
return;
}
User u = new User();
u.setUsername(username);
u.setPassword(encoder.encode(rawPassword));
u.getRoles().addAll(userRoles);
users.save(u);
System.out.println("Seeded " + username + " with " + userRoles.size() + " role(s)");
}
}// src/main/java/in/ignou/admission/security/AppUserDetailsService.java (Session 10, Q42: roles from DB)
package in.ignou.admission.security;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import in.ignou.admission.entity.User;
import in.ignou.admission.repository.UserRepository;
@Service
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository users;
public AppUserDetailsService(UserRepository users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User u = users.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("No user: " + username));
// "ROLE_ADMIN" in the table -> GrantedAuthority "ROLE_ADMIN" -> hasRole("ADMIN") passes
List<GrantedAuthority> authorities = u.getRoles().stream()
.map(r -> (GrantedAuthority) new SimpleGrantedAuthority(r.getName()))
.toList();
return org.springframework.security.core.userdetails.User.withUsername(u.getUsername())
.password(u.getPassword())
.disabled(!u.isEnabled())
.authorities(authorities)
.build();
}
}-- Session 10, Q42: tables Hibernate generates for Role and the User-Role link.
CREATE TABLE roles (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(30) NOT NULL, -- 'ROLE_ADMIN', 'ROLE_STUDENT'
PRIMARY KEY (id),
UNIQUE KEY uk_roles_name (name)
) ENGINE=InnoDB;
-- join table for @ManyToMany: one row per (user, role) pair
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users (id),
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles (id)
) ENGINE=InnoDB;
-- check after the first start-up
SELECT u.username, r.name
FROM users u JOIN user_roles ur ON ur.user_id = u.id JOIN roles r ON r.id = ur.role_id;Output
Checked by reading. Console on the first start after the change:
Hibernate: create table roles (id bigint not null auto_increment, name varchar(30) not null, primary key (id)) engine=InnoDB
Hibernate: create table user_roles (role_id bigint not null, user_id bigint not null, primary key (role_id, user_id)) engine=InnoDB
Hibernate: alter table user_roles add constraint FK... foreign key (role_id) references roles (id)
Hibernate: alter table user_roles add constraint FK... foreign key (user_id) references users (id)
Seeded admin with 2 role(s)
Seeded asha with 1 role(s)The join query from roles.sql:
+----------+--------------+
| username | name |
+----------+--------------+
| admin | ROLE_ADMIN |
| admin | ROLE_STUDENT |
| asha | ROLE_STUDENT |
+----------+--------------+Explanation
A user can hold several roles and a role belongs to many users, so the relation is many-to-many and needs a join table. @JoinTable(name = "user_roles", joinColumns = user_id, inverseJoinColumns = role_id) names it; Hibernate creates it with a composite primary key and two foreign keys. FetchType.EAGER is deliberate: the roles are needed on every login, the set is tiny, and a lazy set would fail with LazyInitializationException once the session closes after loadUserByUsername. The role name carries the ROLE_ prefix in the table so that new SimpleGrantedAuthority(r.getName()) is the exact string Spring compares: hasRole("ADMIN") checks for the authority ROLE_ADMIN. The seeder uses findByName(...).orElseGet(save) so it is safe to run on every start.
Question 43
Problem Statement
Write in lab recordWrite code for role-based authentication using Spring Security.
Solution
Write in lab recordSteps
- Replace
SecurityConfig.javawith the version whoseauthorizeHttpRequestsblock has the role rules. - Replace
PageController.java(it gains/admin/users) and addtemplates/admin.html. - Restart. Log in as
asha, open/admin/users: a white “Whitelabel Error Page” with status 403 Forbidden. Log in asadmin: the user table appears. - Repeat from curl with HTTP Basic to capture the status codes.
Program
Lab record: every tab is one file of the answer. Write all of them.
// src/main/java/in/ignou/admission/security/SecurityConfig.java (Session 10: Q43 role rules, Q45 CSRF)
package in.ignou.admission.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Q43: rules are checked top to bottom, first match wins; put the specific ones first
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register", "/css/**", "/actuator/health").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/actuator/**").hasRole("ADMIN")
.requestMatchers("/api/**", "/xml/**").hasAnyRole("ADMIN", "STUDENT")
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.permitAll())
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll())
.httpBasic(Customizer.withDefaults())
// Q45: CSRF stays ON for every browser form (login, register, logout).
// Only the token-less JSON endpoints used from curl are excluded.
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**", "/xml/**", "/actuator/**"));
return http.build();
}
}// src/main/java/in/ignou/admission/web/PageController.java (Session 10: Q44 dashboard details, Q43 admin page)
package in.ignou.admission.web;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.stream.Collectors;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import in.ignou.admission.repository.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
@Controller
public class PageController {
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm:ss");
private final UserRepository users;
public PageController(UserRepository users) {
this.users = users;
}
@GetMapping("/login")
public String login() {
return "login";
}
/** Q44: Spring injects the current Authentication as a handler-method argument. */
@GetMapping({"/", "/dashboard"})
public String dashboard(Authentication auth, HttpServletRequest request, Model model) {
model.addAttribute("username", auth.getName());
model.addAttribute("roles", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(", ")));
// ponytail: behind a proxy read the X-Forwarded-For header instead
model.addAttribute("clientIp", request.getRemoteAddr());
model.addAttribute("serverTime", LocalDateTime.now().format(FMT));
model.addAttribute("sessionId", request.getSession().getId());
return "dashboard";
}
/** Q43: reachable only with ROLE_ADMIN (rule in SecurityConfig). */
@GetMapping("/admin/users")
public String adminUsers(Model model) {
model.addAttribute("users", users.findAll());
return "admin";
}
}<!DOCTYPE html>
<!-- src/main/resources/templates/admin.html (Session 10, Q43: admin-only page) -->
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>Admin - Users</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container mt-4">
<h4>Registered users <small class="text-muted">(seen by <span sec:authentication="name">admin</span>)</small></h4>
<table class="table table-striped">
<thead><tr><th>Id</th><th>Username</th><th>Enabled</th><th>Roles</th></tr></thead>
<tbody>
<tr th:each="u : ${users}">
<td th:text="${u.id}">1</td>
<td th:text="${u.username}">admin</td>
<td th:text="${u.enabled}">true</td>
<td><span th:each="r : ${u.roles}" th:text="${r.name} + ' '">ROLE_ADMIN</span></td>
</tr>
</tbody>
</table>
<a th:href="@{/dashboard}" class="btn btn-link">Back to dashboard</a>
</div>
</body>
</html>Output
Checked by reading. The access matrix, each row one curl call:
curl -s -o /dev/null -w "%{http_code}\n" -u asha:asha123 http://localhost:8080/admin/users
curl -s -o /dev/null -w "%{http_code}\n" -u admin:admin123 http://localhost:8080/admin/users
curl -s -o /dev/null -w "%{http_code}\n" -u asha:asha123 http://localhost:8080/api/students
curl -s -o /dev/null -w "%{http_code}\n" -u asha:asha123 http://localhost:8080/actuator/metrics
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/actuator/health
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/dashboard403
200
200
403
200
302| URL | Anonymous | asha (STUDENT) | admin (ADMIN, STUDENT) |
|---|---|---|---|
/login, /register, /actuator/health | 200 | 200 | 200 |
/dashboard | 302 to /login | 200 | 200 |
/api/students, /xml/courses | 401 (curl) or 302 (browser) | 200 | 200 |
/admin/users | 401 or 302 | 403 | 200 |
/actuator/metrics, /actuator/env | 401 or 302 | 403 | 200 |
admin.html as admin, with the seeded rows:
Registered users (seen by admin)
Id Username Enabled Roles
1 admin true ROLE_ADMIN ROLE_STUDENT
2 asha true ROLE_STUDENTExplanation
Authentication answers “who are you”, authorisation answers “may you”. The role rules live in authorizeHttpRequests and are evaluated top to bottom, first match wins, so the specific patterns (/admin/**, /actuator/**, /api/**) come before anyRequest(). hasRole("ADMIN") is shorthand for hasAuthority("ROLE_ADMIN"); hasAnyRole accepts any of the list. An authenticated user who fails a rule gets 403 from ExceptionTranslationFilter, while an anonymous one gets the login redirect (or 401 for Basic clients), which is why the same URL shows two different failures in the matrix. /actuator/health stays open so a monitoring tool can poll it without a password, but the rest of Actuator is admin-only because env and heapdump leak secrets. admin holds both roles so the same person can test every row of the matrix.
Question 44
Problem Statement
Write in lab recordDisplay current logged in User details on the dashboard along with client IP, data time and user’s current role.
Solution
Write in lab recordSteps
PageController.dashboard(replaced in Question 43) takesAuthenticationandHttpServletRequestas parameters and putsusername,roles,clientIp,serverTimeandsessionIdin the model.- Replace
templates/dashboard.htmlwith the listing. - Log in as
admin, then asasha, and compare the Role(s) row and the buttons (buttons belong to Question 46). - Open the dashboard from another machine on the LAN (
http://YOUR-IP:8080/dashboard) to see a client IP other than127.0.0.1.
Program
<!DOCTYPE html>
<!-- src/main/resources/templates/dashboard.html (Session 10: Q44 user details, Q46 sec:authorize) -->
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<!-- Q45: token for JavaScript callers (fetch/XMLHttpRequest) -->
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
<title>Admission Portal - Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<nav class="navbar navbar-dark bg-dark">
<div class="container">
<span class="navbar-brand">Student Admission Portal</span>
<form th:action="@{/logout}" method="post" class="d-flex">
<button type="submit" class="btn btn-outline-light btn-sm">Logout</button>
</form>
</div>
</nav>
<div class="container mt-4">
<div class="card shadow-sm">
<div class="card-header">Current session</div>
<table class="table mb-0">
<tr><th>User</th><td th:text="${username}">admin</td></tr>
<tr><th>Role(s)</th><td th:text="${roles}">ROLE_ADMIN</td></tr>
<tr><th>Client IP</th><td th:text="${clientIp}">127.0.0.1</td></tr>
<tr><th>Date and time</th><td th:text="${serverTime}">01-09-2026 10:15:42</td></tr>
<tr><th>Session id</th><td th:text="${sessionId}"></td></tr>
</table>
</div>
<div class="mt-4">
<!-- Q46: everyone who is logged in sees this -->
<a sec:authorize="isAuthenticated()" th:href="@{/api/students}" class="btn btn-primary">Students (JSON)</a>
<!-- Q46: rendered only when the authority ROLE_ADMIN is present -->
<a sec:authorize="hasRole('ADMIN')" th:href="@{/admin/users}" class="btn btn-danger">Admin: user list</a>
<a sec:authorize="hasRole('ADMIN')" th:href="@{/actuator/health}" class="btn btn-secondary">Health</a>
<p sec:authorize="!hasRole('ADMIN')" class="text-muted mt-2">Admin functions are hidden for your role.</p>
</div>
</div>
</body>
</html>The handler that fills the model, from PageController above:
@GetMapping({"/", "/dashboard"})
public String dashboard(Authentication auth, HttpServletRequest request, Model model) {
model.addAttribute("username", auth.getName());
model.addAttribute("roles", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(", ")));
model.addAttribute("clientIp", request.getRemoteAddr());
model.addAttribute("serverTime", LocalDateTime.now().format(FMT));
model.addAttribute("sessionId", request.getSession().getId());
return "dashboard";
}Output
Checked by reading. Logged in as admin from the same machine:
Current session
User admin
Role(s) ROLE_ADMIN, ROLE_STUDENT
Client IP 127.0.0.1
Date and time 26-09-2026 10:15:42
Session id 3F1A9C2E7B5D4A6F8E0C1B2D3A4F5E6C
[Students (JSON)] [Admin: user list] [Health]Logged in as asha from a laptop on the same Wi-Fi:
User asha
Role(s) ROLE_STUDENT
Client IP 192.168.1.23
Date and time 26-09-2026 10:17:05
Session id 9B7E2D4C1F0A3E5B6D8C7A9F2E1B4C3D
[Students (JSON)]
Admin functions are hidden for your role.If the browser uses IPv6 for localhost the IP row shows 0:0:0:0:0:0:0:1; that is correct, not a bug.
Explanation
Spring MVC resolves an Authentication parameter from the SecurityContextHolder, so the controller never touches the holder directly. auth.getName() is the username; getAuthorities() is the collection built in AppUserDetailsService, joined into one string for display. The IP comes from the servlet request; getRemoteAddr() is the TCP peer, which is the client on a LAN and the proxy if one is in front (then read X-Forwarded-For). The time is the server clock, formatted once with a shared DateTimeFormatter because the formatter is thread-safe and the pattern never changes. The session id is included because it is the value the logout in Session 9 destroys; comparing it before and after logout is a quick viva demonstration.
Question 45
Problem Statement
Write in lab recordAdd CSRF functionality in the authentication.
Solution
Write in lab recordSteps
- Nothing to add:
CsrfFilteris part of the chain unless.csrf(csrf -> csrf.disable())is written. The work is to carry the token on every state-changing form and to prove the filter rejects requests without it. login.html(Question 41) uses a plainaction="/login"with an explicit hidden input built from_csrf.parameterNameand_csrf.token;register.htmland the logout form useth:action, which inserts the same field automatically. View source on both pages and find the hidden input.dashboard.htmlcarries two<meta>tags with the token and header name for JavaScript callers.- Run the curl tests below: a POST without the token gets 403, the same POST with the token gets 302.
- The API is excluded with
ignoringRequestMatchers("/api/**", "/xml/**", "/actuator/**")inSecurityConfig; keep that line and be ready to explain it.
Program
The pieces, all in files already listed:
<!-- login.html: token by hand -->
<form action="/login" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<!-- register.html and the logout form: th:action adds the same hidden input -->
<form th:action="@{/register}" th:object="${form}" method="post">
<!-- dashboard.html: for fetch() calls -->
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">// SecurityConfig: CSRF on for browser forms, off for the token-less JSON API
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**", "/xml/**", "/actuator/**"))Output
Checked by reading. The rendered login form contains:
<input type="hidden" name="_csrf" value="mK4Zr9tQ-...-64 url-safe characters">Without a token the login POST is rejected before the credentials are even read:
curl -i -X POST http://localhost:8080/login -d "username=admin" -d "password=admin123"HTTP/1.1 403With the token from a fresh GET, using the same cookie jar (the token is bound to the session):
TOKEN=$(curl -s -c jar.txt http://localhost:8080/login | grep -o 'name="_csrf" value="[^"]*' | cut -d'"' -f4)
curl -i -b jar.txt -c jar.txt -X POST http://localhost:8080/login \
-d "username=admin" -d "password=admin123" -d "_csrf=$TOKEN"HTTP/1.1 302
Location: http://localhost:8080/dashboardThe excluded API still accepts writes with Basic auth and no token:
curl -s -o /dev/null -w "%{http_code}\n" -u admin:admin123 -X DELETE http://localhost:8080/api/students/2204Using a token from one session with the cookie of another also gives 403, which is the actual attack the filter stops.
Explanation
A cross-site request forgery is a form on an attacker’s page that posts to our site; the browser attaches our session cookie automatically, so the request looks logged in. The defence is a second secret the attacker cannot read: a random token stored in the session and echoed in every form. CsrfFilter compares the _csrf parameter (or the X-CSRF-TOKEN header) with the session’s token on every POST, PUT, PATCH and DELETE, and answers 403 on mismatch; GET is never checked because it must not change state. Spring Security 6 also wraps the token per request (XorCsrfTokenRequestAttributeHandler), so the value in the page differs each time while validating to the same secret. The API is excluded because its clients authenticate with HTTP Basic on each call and hold no session cookie an attacker could reuse.
Question 46
Problem Statement
Write in lab recordRestrict role-based access to views in Spring Boot Security.
Solution
Write in lab recordSteps
- Add
thymeleaf-extras-springsecurity6topom.xmland run Maven, Update Project. Boot’s parent manages the version. - In the templates, declare
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"on thehtmltag (already indashboard.htmlandadmin.html). - Use
sec:authorize="hasRole('ADMIN')"on the elements only admins may see,sec:authorize="isAuthenticated()"for any logged-in user, andsec:authentication="name"to print the username. - Log in as
asha, thenadmin, and compare the dashboard buttons. Also type/admin/usersasasha: the link is hidden and the URL rule still returns 403.
Program
<!-- Session 10, Q46: sec:authorize / sec:authentication in Thymeleaf. Add inside <dependencies>. -->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>The view rules, from dashboard.html and admin.html above:
<a sec:authorize="isAuthenticated()" th:href="@{/api/students}" class="btn btn-primary">Students (JSON)</a>
<a sec:authorize="hasRole('ADMIN')" th:href="@{/admin/users}" class="btn btn-danger">Admin: user list</a>
<a sec:authorize="hasRole('ADMIN')" th:href="@{/actuator/health}" class="btn btn-secondary">Health</a>
<p sec:authorize="!hasRole('ADMIN')" class="text-muted mt-2">Admin functions are hidden for your role.</p>
<span sec:authentication="name">admin</span>Output
Checked by reading. Rendered HTML of the button block for admin:
<a href="/api/students" class="btn btn-primary">Students (JSON)</a>
<a href="/admin/users" class="btn btn-danger">Admin: user list</a>
<a href="/actuator/health" class="btn btn-secondary">Health</a>For asha, the two admin anchors are absent from the page source, not just hidden by CSS:
<a href="/api/students" class="btn btn-primary">Students (JSON)</a>
<p class="text-muted mt-2">Admin functions are hidden for your role.</p>And asha typing /admin/users by hand still gets the 403 from Question 43.
Explanation
sec:authorize evaluates the same SpEL expressions as authorizeHttpRequests (hasRole, hasAnyRole, isAuthenticated, !), against the Authentication in the current request. When the expression is false, Thymeleaf removes the element from the output entirely, so nothing leaks into view source. This is a convenience layer, not the security boundary: the URL rule in SecurityConfig is what actually protects /admin/users. Both are needed. Without the view rule, students see links that fail; without the URL rule, anyone who guesses the address gets in. sec:authentication="name" reads a property of the Authentication object, the same value the controller put in the model as username.
Viva Questions
Do not copy. Read for understanding and the viva- Q: Why is User to Role many-to-many and what table does JPA create for it? A: A user has several roles and a role has many users;
user_roles(user_id, role_id)with a composite key. - Q: What is the difference between
hasRole("ADMIN")andhasAuthority("ROLE_ADMIN")? A: None in effect;hasRoleadds theROLE_prefix before comparing. - Q: Why are the roles fetched eagerly? A: They are needed at every login and the session is closed once
loadUserByUsernamereturns; a lazy set would throwLazyInitializationException. - Q: Why does a logged-in student get 403 on
/admin/userswhile an anonymous visitor gets a redirect? A:ExceptionTranslationFiltersends unauthenticated users to log in; authenticated users who fail authorisation get Access Denied. - Q: How does the controller obtain the current user without touching
SecurityContextHolder? A: Spring MVC injectsAuthentication(or@AuthenticationPrincipal) as a handler argument. - Q: Which HTTP methods does
CsrfFiltercheck? A: POST, PUT, PATCH and DELETE; GET, HEAD, OPTIONS and TRACE are exempt. - Q: Why exclude
/api/**from CSRF? A: Its clients use HTTP Basic per request and carry no session cookie, so the forgery scenario does not apply. - Q: Is
sec:authorizeenough to protect a page? A: No. It only hides markup; the URL rule in theSecurityFilterChainis the real check.
Common Mistakes
Do not copy. Read for understanding and the viva- Storing
ADMINin the role table and callinghasRole("ADMIN"); the authority must beROLE_ADMINorhasAuthoritymust be used. - Putting
anyRequest().authenticated()before the/admin/**rule; the first match wins and the admin rule is never reached (Spring 6 refuses to start when a pattern followsanyRequest()). - Disabling CSRF globally to make one curl call work, instead of excluding just the JSON paths.
- Reading the client IP behind a proxy with
getRemoteAddr()and reporting the proxy’s address. - Forgetting
thymeleaf-extras-springsecurity6;sec:attributes are silently ignored and every user sees every link. - Using
sec:authorizealone and skipping the URL rule, so the admin page is reachable by typing its address.
Session Summary
Write in lab record- Bootstrap
login.htmlandregister.htmlwith the CDN link and theis-invalid/invalid-feedbackvalidation display Role,RoleRepository, the many-to-manyUser, the seeder with two roles and two users, therolesanduser_rolesDDL and the join-query resultSecurityConfigwith the role rules,admin.html, and the access matrix with one curl per celldashboard.htmlshowing username, roles, client IP, date and time and session id, with sample renderings for admin and student- The CSRF hidden field, meta tags, the 403-then-302 curl transcript and the API exclusion
sec:authorizeview rules and the rendered button block for each role