Real applications keep users in a database and show their own login page. This session replaces Spring Security’s defaults with a User entity, a custom UserDetailsService, a custom login form, logout, and registration with automatic login.
Objectives
Do not copy. Read for understanding and the viva- Complete questions 37 to 40 of the manual: spring security
- 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 |
|---|---|---|
| Q37 | Create a User table into the database and bind the user entity with Spring Security… | Complete |
| Q38 | Create a Custom Login Page in HTML and authenticate using Spring Security in Spring Boot | Complete |
| Q39 | Implement logout functionality in Spring Security | Complete |
| Q40 | Create a User registration form and validate the form. Once information is validated… | Complete |
Preparation
Do not copy. Read for understanding and the viva- User table: id, username, password (BCrypt hash), enabled. Never store plain passwords; use
BCryptPasswordEncoder. - The
SecurityFilterChainbean configuresformLogin().loginPage("/login")andlogout(). - Auto-login after registration means authenticating programmatically with
AuthenticationManagerand placing the result in theSecurityContextHolder.
The project is still admission-api with the security starter from Session 8. SecurityConfig.java is one file that grows across Questions 37 to 39; it is listed once, under Question 38, with the parts labelled. Nothing was executed here; every listing and expected page was checked by reading against Spring Security 6.3.
Question 37
Problem Statement
Write in lab recordCreate a User table into the database and bind the user entity with Spring Security for Login.
Solution
Write in lab recordSteps
- Add the
Userentity inin.ignou.admission.entityandUserRepositoryinin.ignou.admission.repository. - Create the package
in.ignou.admission.securitywithAppUserDetailsServiceandDataSeeder. - Create
SecurityConfigin the same package with thePasswordEncoderbean (full file under Question 38). - Restart. Hibernate creates
users; the seeder insertsadminwith a BCrypt hash; the console no longer prints a generated password. - Check in MySQL:
SELECT id, username, LEFT(password, 7), enabled FROM users;. - Log in at
/login(still Spring’s default page at this point) asadmin/admin123.
Program
Lab record: every tab is one file of the answer. Write all of them.
// src/main/java/in/ignou/admission/entity/User.java (Session 9, Q37)
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;
/** Login account. @Table("users") because USER is a reserved word in several databases. */
@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;
/** BCrypt hash, never the raw password. 60 characters. */
@Column(nullable = false, length = 100)
private String password;
private boolean enabled = true;
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; }
}// src/main/java/in/ignou/admission/repository/UserRepository.java (Session 9, Q37)
package in.ignou.admission.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import in.ignou.admission.entity.User;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}// src/main/java/in/ignou/admission/security/AppUserDetailsService.java (Session 9, Q37)
package in.ignou.admission.security;
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;
/**
* The bridge between the users table and Spring Security. The framework calls
* loadUserByUsername at login, then compares the submitted password with the
* stored hash through the PasswordEncoder bean.
*/
@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));
// Spring's own User class (org.springframework.security.core.userdetails.User)
return org.springframework.security.core.userdetails.User.withUsername(u.getUsername())
.password(u.getPassword()) // already BCrypt-hashed
.disabled(!u.isEnabled())
.roles("USER") // Session 10 replaces this with roles from the DB
.build();
}
}// src/main/java/in/ignou/admission/security/DataSeeder.java (Session 9, Q37: first login account)
package in.ignou.admission.security;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import in.ignou.admission.entity.User;
import in.ignou.admission.repository.UserRepository;
/** Runs once after start-up. Hashes the password in Java, so no hash needs to be pasted into SQL. */
@Component
public class DataSeeder implements CommandLineRunner {
private final UserRepository users;
private final PasswordEncoder encoder;
public DataSeeder(UserRepository users, PasswordEncoder encoder) {
this.users = users;
this.encoder = encoder;
}
@Override
public void run(String... args) {
if (users.existsByUsername("admin")) {
return;
}
User admin = new User();
admin.setUsername("admin");
admin.setPassword(encoder.encode("admin123"));
users.save(admin);
System.out.println("Seeded user admin / admin123");
}
}The table Hibernate produces, for the record and for anyone running with ddl-auto=none:
-- Session 9, Q37: what Hibernate generates for the User entity (ddl-auto=update).
-- Run by hand only if you keep ddl-auto=none.
CREATE TABLE users (
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
password VARCHAR(100) NOT NULL, -- BCrypt hash, always 60 chars
enabled BIT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_users_username (username)
) ENGINE=InnoDB;Output
Checked by reading. Console on first start:
Hibernate: create table users (enabled bit not null, id bigint not null auto_increment, username varchar(50) not null, password varchar(100) not null, primary key (id)) engine=InnoDB
Hibernate: alter table users add constraint UK_users_username unique (username)
Hibernate: insert into users (enabled,password,username) values (?,?,?)
Seeded user admin / admin123MySQL:
+----+----------+------------------+---------+
| id | username | LEFT(password,7) | enabled |
+----+----------+------------------+---------+
| 1 | admin | $2a$10$ | 1 |
+----+----------+------------------+---------+The Using generated security password line is gone: a UserDetailsService bean of our own switches off the in-memory user. Logging in as admin / admin123 works; admin / wrong shows “Bad credentials”; a username that is not in the table shows the same message (Spring hides the difference on purpose).
Explanation
Spring Security never reads a table itself. It calls UserDetailsService.loadUserByUsername, receives a UserDetails (username, hashed password, enabled flags, authorities) and then asks the PasswordEncoder to compare the submitted password with the stored hash. AppUserDetailsService is that adapter: it loads our entity through UserRepository and copies it into Spring’s own User builder. The entity is named User too, so the Spring class is referenced by its full name to avoid an import clash. BCryptPasswordEncoder.encode produces a 60-character string beginning $2a$10$; the salt is inside the string, so the same password gives a different hash each time and matches still works. The seeder hashes in Java because a hash cannot be typed by hand into data.sql. @Table(name = "users") avoids the reserved word USER in several databases.
Question 38
Problem Statement
Write in lab recordCreate a Custom Login Page in HTML and authenticate using Spring Security in Spring Boot.
Solution
Write in lab recordSteps
- Add
PageControllerinin.ignou.admission.webwithGET /loginandGET /dashboard. - Add
login.htmlanddashboard.htmlundersrc/main/resources/templates. - Complete
SecurityConfigwith theSecurityFilterChainbean:/loginpermitted for all,loginPage("/login"), success URL/dashboard. - Restart. Open
http://localhost:8080/dashboard; you are redirected to your own/loginpage. - Submit
admin/admin123; the dashboard shows “Welcome, admin”. Submit a wrong password; the page returns with the red error line and?errorin the URL.
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 9: Q37 encoder, Q38 login page, Q39 logout)
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 {
/** Q37: BCrypt with a random salt per password; strength 10 (default) = 2^10 rounds. */
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/** Q40 uses this to log the new user in programmatically. */
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register", "/css/**", "/actuator/health").permitAll()
.anyRequest().authenticated())
// Q38: our own page at GET /login; the POST /login handler is still Spring's
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.permitAll())
// Q39: POST /logout ends the session and returns to the login page
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll())
// lets curl -u user:pass call /api/** (browser still uses the form)
.httpBasic(Customizer.withDefaults())
// the JSON API is called by curl/Postman, not from a browser session: no CSRF token there
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**", "/xml/**", "/actuator/**"));
return http.build();
}
}// src/main/java/in/ignou/admission/web/PageController.java (Session 9, Q38)
package in.ignou.admission.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/** Plain @Controller: return values are Thymeleaf template names under src/main/resources/templates. */
@Controller
public class PageController {
@GetMapping("/login")
public String login() {
return "login"; // templates/login.html
}
@GetMapping({"/", "/dashboard"})
public String dashboard() {
return "dashboard"; // templates/dashboard.html
}
}<!DOCTYPE html>
<!-- src/main/resources/templates/login.html (Session 9, Q38) -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Admission Portal - Login</title>
</head>
<body>
<h2>Student Admission Portal</h2>
<p th:if="${param.error}" style="color:red">Invalid username or password.</p>
<p th:if="${param.logout}" style="color:green">You have been logged out.</p>
<!-- th:action posts to /login, the URL Spring Security listens on, and adds the CSRF token -->
<form th:action="@{/login}" method="post">
<p>
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</p>
<p>
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</p>
<button type="submit">Sign in</button>
</form>
<p>New student? <a th:href="@{/register}">Register here</a></p>
</body>
</html><!DOCTYPE html>
<!-- src/main/resources/templates/dashboard.html (Session 9, Q38 landing page + Q39 logout button) -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Admission Portal - Dashboard</title>
</head>
<body>
<h2>Dashboard</h2>
<p>Welcome, <span th:text="${#authentication.name}">user</span>.</p>
<ul>
<li><a th:href="@{/api/students}">Students (JSON)</a></li>
<li><a th:href="@{/actuator/health}">Health</a></li>
</ul>
<!-- Q39: logout must be a POST (CSRF-protected); a plain GET link would be rejected -->
<form th:action="@{/logout}" method="post">
<button type="submit">Logout</button>
</form>
</body>
</html>Output
Checked by reading. Browser flow:
GET /dashboardwhile logged out:302 Location: http://localhost:8080/login./loginrenders the heading “Student Admission Portal”, two inputs, a Sign in button and a Register link. View source shows a hidden field_csrfinside the form, added byth:action.- Wrong password:
302 Location: /login?error, and the page shows “Invalid username or password.” in red. - Right password:
302 Location: /dashboard, page shows “Welcome, admin.” with two links and a Logout button.
The same flow from curl, keeping cookies in a jar:
curl -s -c jar.txt http://localhost:8080/login | grep _csrf<input type="hidden" name="_csrf" value="Zt3k9...">curl -i -b jar.txt -c jar.txt -X POST http://localhost:8080/login \
-d "username=admin" -d "password=admin123" -d "_csrf=Zt3k9..."HTTP/1.1 302
Location: http://localhost:8080/dashboardcurl -s -b jar.txt http://localhost:8080/dashboard | grep Welcome<p>Welcome, <span>admin</span>.</p>The JSON API also works with HTTP Basic now, including writes, because /api/** is excluded from CSRF:
curl -s -u admin:admin123 http://localhost:8080/api/students/1{"id":1,"name":"Asha Verma","email":"asha@example.com","phone":"9876543210","city":"Jaipur","dateOfBirth":"2002-03-14"}Explanation
Only the GET half of /login is ours. PageController.login() returns the Thymeleaf template; the POST to /login is still handled by UsernamePasswordAuthenticationFilter, which reads the username and password parameters (the input names must match), calls the AuthenticationManager, and on success saves the SecurityContext in the HTTP session and redirects. defaultSuccessUrl("/dashboard", true) forces the redirect target; without true Spring sends the user back to whatever protected URL they first asked for. permitAll() on the login block covers /login, /login?error and /login?logout. The param.error expression in the template reads the query string, which is how the page knows to show the message. httpBasic stays on so curl and Postman can call the API with -u; csrf.ignoringRequestMatchers("/api/**", ...) lets those token-less calls through while every browser form keeps its token.
Question 39
Problem Statement
Write in lab recordImplement logout functionality in Spring Security.
Solution
Write in lab recordSteps
- The
.logout(...)block inSecurityConfig(listed under Question 38) sets the URL, the redirect, session invalidation and cookie removal. dashboard.htmlalready carries the Logout form. It must be a form withmethod="post"; a plain link does not work.- Log in, click Logout. The browser lands on
/login?logoutwith the green “You have been logged out.” line. - Press the browser Back button: the dashboard does not reappear;
/dashboardredirects to/loginbecause the session is gone.
Program
The two parts, extracted from the files above:
// SecurityConfig.filterChain, Q39 part
.logout(logout -> logout
.logoutUrl("/logout") // POST /logout triggers LogoutFilter
.logoutSuccessUrl("/login?logout") // where to go afterwards
.invalidateHttpSession(true) // drop the server-side session
.deleteCookies("JSESSIONID") // and tell the browser to forget it
.permitAll())<!-- dashboard.html, Q39 part -->
<form th:action="@{/logout}" method="post">
<button type="submit">Logout</button>
</form>Output
Checked by reading. Browser: after clicking Logout the address bar shows http://localhost:8080/login?logout and the page shows “You have been logged out.” Re-entering /dashboard by hand redirects to /login.
curl, continuing from the jar of Question 38 (a fresh CSRF token is read from the dashboard first):
TOKEN=$(curl -s -b jar.txt http://localhost:8080/dashboard | grep -o '_csrf" value="[^"]*' | cut -d'"' -f3)
curl -i -b jar.txt -c jar.txt -X POST http://localhost:8080/logout -d "_csrf=$TOKEN"HTTP/1.1 302
Location: http://localhost:8080/login?logout
Set-Cookie: JSESSIONID=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:10 GMT; Path=/curl -i -b jar.txt http://localhost:8080/dashboardHTTP/1.1 302
Location: http://localhost:8080/loginA GET to /logout does nothing useful (it is not a mapping of ours and CSRF only protects state-changing methods): with the form login active, GET /logout is simply redirected to /login like any other protected URL. A POST without the token returns 403.
Explanation
LogoutFilter sits early in the chain and only reacts to POST /logout (POST because CSRF is enabled; with CSRF off, GET would also work). It runs the configured handlers in order: SecurityContextLogoutHandler clears the SecurityContextHolder and invalidates the HttpSession, CookieClearingLogoutHandler writes a Set-Cookie with Max-Age=0 for JSESSIONID, and then SimpleUrlLogoutSuccessHandler redirects to /login?logout. Because the session object is destroyed on the server, the old JSESSIONID value is useless even if the browser still has it, which is why the Back button test fails cleanly.
Question 40
Problem Statement
Write in lab recordCreate a User registration form and validate the form. Once information is validated and saved, write functionality to auto-login using Spring Security.
Solution
Write in lab recordSteps
- Add
spring-boot-starter-validationtopom.xmland run Maven, Update Project. - Add
RegistrationFormandRegistrationControllerinin.ignou.admission.web, andregister.htmlintemplates. /registeris already in thepermitAll()list ofSecurityConfig;authenticationManageris already a bean there.- Restart. Open
/register, submit an empty form: three red messages. Submitabas the username: the length message. Submit mismatched passwords: “Passwords do not match”. Submitadmin: “Username is already taken”. - Submit a valid form (
ravi_k,ravi123,ravi123): the browser lands on/dashboardshowing “Welcome, ravi_k.” without visiting the login page. - Check MySQL:
SELECT username, LEFT(password,7) FROM users;shows the second row with a$2a$10$hash.
Program
Lab record: every tab is one file of the answer. Write all of them.
<!-- Session 9, Q40: Bean Validation (@NotBlank, @Size, @Email). Add inside <dependencies>. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>// src/main/java/in/ignou/admission/web/RegistrationForm.java (Session 9, Q40)
package in.ignou.admission.web;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/** Form-backing object. Not an entity: it carries confirmPassword, which is never stored. */
public class RegistrationForm {
@NotBlank(message = "Username is required")
@Size(min = 4, max = 50, message = "Username must be 4 to 50 characters")
@Pattern(regexp = "[a-zA-Z0-9_]+", message = "Letters, digits and underscore only")
private String username;
@NotBlank(message = "Password is required")
@Size(min = 6, max = 40, message = "Password must be 6 to 40 characters")
private String password;
@NotBlank(message = "Confirm the password")
private String confirmPassword;
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 String getConfirmPassword() { return confirmPassword; }
public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; }
}// src/main/java/in/ignou/admission/web/RegistrationController.java (Session 9, Q40)
package in.ignou.admission.web;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import in.ignou.admission.entity.User;
import in.ignou.admission.repository.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
@Controller
public class RegistrationController {
private final UserRepository users;
private final PasswordEncoder encoder;
private final AuthenticationManager authenticationManager;
// saves the SecurityContext into the HTTP session so the next request is still logged in
private final SecurityContextRepository contextRepository = new HttpSessionSecurityContextRepository();
public RegistrationController(UserRepository users, PasswordEncoder encoder,
AuthenticationManager authenticationManager) {
this.users = users;
this.encoder = encoder;
this.authenticationManager = authenticationManager;
}
@GetMapping("/register")
public String form(@ModelAttribute("form") RegistrationForm form) {
return "register";
}
/**
* @Valid runs the annotations on RegistrationForm; BindingResult must be the very next
* parameter, otherwise Spring throws instead of letting us re-show the form.
*/
@PostMapping("/register")
public String register(@Valid @ModelAttribute("form") RegistrationForm form, BindingResult result,
HttpServletRequest request, HttpServletResponse response) {
if (!result.hasFieldErrors("confirmPassword") && !form.getPassword().equals(form.getConfirmPassword())) {
result.rejectValue("confirmPassword", "mismatch", "Passwords do not match");
}
if (!result.hasFieldErrors("username") && users.existsByUsername(form.getUsername())) {
result.rejectValue("username", "taken", "Username is already taken");
}
if (result.hasErrors()) {
return "register"; // re-render with th:errors messages
}
User user = new User();
user.setUsername(form.getUsername());
user.setPassword(encoder.encode(form.getPassword()));
users.save(user);
// --- auto-login: the same path the login form takes, done in code ---
Authentication auth = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(form.getUsername(), form.getPassword()));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context); // current thread
contextRepository.saveContext(context, request, response); // HTTP session
return "redirect:/dashboard";
}
}<!DOCTYPE html>
<!-- src/main/resources/templates/register.html (Session 9, Q40) -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Admission Portal - Register</title>
</head>
<body>
<h2>Create your account</h2>
<!-- th:object binds the form to the "form" model attribute; th:field fills name, id and value -->
<form th:action="@{/register}" th:object="${form}" method="post">
<p>
<label for="username">Username</label>
<input type="text" th:field="*{username}" required>
<span th:if="${#fields.hasErrors('username')}" th:errors="*{username}" style="color:red"></span>
</p>
<p>
<label for="password">Password</label>
<input type="password" th:field="*{password}" required>
<span th:if="${#fields.hasErrors('password')}" th:errors="*{password}" style="color:red"></span>
</p>
<p>
<label for="confirmPassword">Confirm password</label>
<input type="password" th:field="*{confirmPassword}" required>
<span th:if="${#fields.hasErrors('confirmPassword')}" th:errors="*{confirmPassword}" style="color:red"></span>
</p>
<button type="submit">Register and sign in</button>
</form>
<p>Already registered? <a th:href="@{/login}">Login</a></p>
</body>
</html>Output
Checked by reading. Validation messages as rendered next to each field:
| Input | Message shown |
|---|---|
| empty username | Username is required |
ab | Username must be 4 to 50 characters |
ravi k (space) | Letters, digits and underscore only |
admin | Username is already taken |
password 123 | Password must be 6 to 40 characters |
| confirm differs | Passwords do not match |
Successful registration from curl (token from the register page, then the POST, then the dashboard with the same jar):
curl -s -c jar2.txt http://localhost:8080/register | grep -o '_csrf" value="[^"]*' | cut -d'"' -f3Q7pM2...curl -i -b jar2.txt -c jar2.txt -X POST http://localhost:8080/register \
-d "username=ravi_k" -d "password=ravi123" -d "confirmPassword=ravi123" -d "_csrf=Q7pM2..."HTTP/1.1 302
Location: http://localhost:8080/dashboardcurl -s -b jar2.txt http://localhost:8080/dashboard | grep Welcome<p>Welcome, <span>ravi_k</span>.</p>Console during the POST:
Hibernate: select u1_0.id,u1_0.enabled,u1_0.password,u1_0.username from users u1_0 where u1_0.username=?
Hibernate: insert into users (enabled,password,username) values (?,?,?)
Hibernate: select u1_0.id,u1_0.enabled,u1_0.password,u1_0.username from users u1_0 where u1_0.username=?The first select is existsByUsername, the insert is save, and the last select is loadUserByUsername called by the AuthenticationManager during auto-login.
Explanation
Validation runs in two layers. The annotations on RegistrationForm (@NotBlank, @Size, @Pattern) are Bean Validation constraints; @Valid on the handler parameter triggers them, and the errors land in the BindingResult declared right after the parameter. Checks that need data or two fields, password match and username uniqueness, are added by hand with rejectValue, which puts them in the same BindingResult so the template shows them the same way through th:errors. Only when hasErrors() is false is the entity built; the raw password is hashed once and the form object is discarded.
Auto-login repeats what the login filter does. UsernamePasswordAuthenticationToken.unauthenticated(username, rawPassword) is the request; authenticationManager.authenticate returns an authenticated token after AppUserDetailsService and BCrypt agree. The token goes into a fresh SecurityContext, which is set on the holder for the current thread and saved to the HTTP session by HttpSessionSecurityContextRepository. That save is the step students miss: Spring Security 6 no longer saves the context automatically, so without saveContext the redirect to /dashboard would arrive with an anonymous session and bounce to /login.
Viva Questions
Do not copy. Read for understanding and the viva- Q: What does Spring Security call to look up a user, and what does it get back? A:
UserDetailsService.loadUserByUsername; it returns aUserDetailswith username, hashed password, flags and authorities. - Q: Why does BCrypt give a different hash for the same password each time? A: It generates a random salt and stores it inside the hash string;
matchesre-reads the salt. - Q: Which part of
/loginis our code and which is Spring’s? A: The GET page is ours; the POST is handled byUsernamePasswordAuthenticationFilter. - Q: Why must the logout button be a POST form? A: CSRF protection is on, so
LogoutFilteronly accepts POST with a valid token. - Q: Why must
BindingResultfollow the@Validparameter directly? A: Spring binds errors to the immediately preceding model attribute; otherwise it throwsMethodArgumentNotValidException. - Q: Why is
confirmPasswordin the form class but not in the entity? A: It exists only to validate input; it is never stored. - Q: Which line makes the auto-login survive the redirect? A:
contextRepository.saveContext(context, request, response), which stores the context in the HTTP session. - Q: Why does the seeder hash the password in Java instead of
data.sql? A: BCrypt output cannot be typed by hand;encodeproduces a salted hash at run time.
Common Mistakes
Do not copy. Read for understanding and the viva- Storing the raw password or hashing with MD5/SHA; Spring refuses raw passwords (
There is no PasswordEncoder mapped for the id "null") and SHA has no salt. - Naming the inputs
userandpass; Spring readsusernameandpasswordand every login fails with “Bad credentials”. - Writing the login form with
action="/login"instead ofth:action, which drops the CSRF token and produces a 403 on submit. - Using a link
<a href="/logout">for logout and reporting that logout does not work. - Forgetting
spring-boot-starter-validation;@Validcompiles but no constraint runs and empty forms are saved. - Setting the
SecurityContextHolderbut not saving the context to the session, so the auto-login is lost on the very next request.
Session Summary
Write in lab recordUserentity,UserRepository,AppUserDetailsService,DataSeederand the generateduserstable with one BCrypt rowSecurityConfigwithPasswordEncoder,AuthenticationManagerand theSecurityFilterChain(custom login, logout, basic auth, CSRF exclusions for the API)PageController,login.htmlanddashboard.htmlwith the browser flow and the curl transcript- The logout block, the POST form and the transcript showing the 302 to
/login?logoutand the cleared cookie RegistrationForm,RegistrationController,register.html, the validation message table and the auto-login transcript