Skip to content

Session 9

Spring Security

Updated View as Markdown

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
QuestionRequirementStatus
Q37Create a User table into the database and bind the user entity with Spring Security…Complete
Q38Create a Custom Login Page in HTML and authenticate using Spring Security in Spring BootComplete
Q39Implement logout functionality in Spring SecurityComplete
Q40Create 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 SecurityFilterChain bean configures formLogin().loginPage("/login") and logout().
  • Auto-login after registration means authenticating programmatically with AuthenticationManager and placing the result in the SecurityContextHolder.

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 record

Create a User table into the database and bind the user entity with Spring Security for Login.

Solution

Write in lab record

Steps

  1. Add the User entity in in.ignou.admission.entity and UserRepository in in.ignou.admission.repository.
  2. Create the package in.ignou.admission.security with AppUserDetailsService and DataSeeder.
  3. Create SecurityConfig in the same package with the PasswordEncoder bean (full file under Question 38).
  4. Restart. Hibernate creates users; the seeder inserts admin with a BCrypt hash; the console no longer prints a generated password.
  5. Check in MySQL: SELECT id, username, LEFT(password, 7), enabled FROM users;.
  6. Log in at /login (still Spring’s default page at this point) as admin / admin123.

Program

Lab record: every tab is one file of the answer. Write all of them.

User.javajava
// 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; }
}
UserRepository.javajava
// 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);
}
AppUserDetailsService.javajava
// 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();
    }
}
DataSeeder.javajava
// 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:

users.sqlsql
-- 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 / admin123

MySQL:

+----+----------+------------------+---------+
| 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 record

Create a Custom Login Page in HTML and authenticate using Spring Security in Spring Boot.

Solution

Write in lab record

Steps

  1. Add PageController in in.ignou.admission.web with GET /login and GET /dashboard.
  2. Add login.html and dashboard.html under src/main/resources/templates.
  3. Complete SecurityConfig with the SecurityFilterChain bean: /login permitted for all, loginPage("/login"), success URL /dashboard.
  4. Restart. Open http://localhost:8080/dashboard; you are redirected to your own /login page.
  5. Submit admin / admin123; the dashboard shows “Welcome, admin”. Submit a wrong password; the page returns with the red error line and ?error in the URL.

Program

Lab record: every tab is one file of the answer. Write all of them.

SecurityConfig.javajava
// 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();
    }
}
PageController.javajava
// 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
    }
}
login.htmlhtml
<!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>
dashboard.htmlhtml
<!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:

  1. GET /dashboard while logged out: 302 Location: http://localhost:8080/login.
  2. /login renders the heading “Student Admission Portal”, two inputs, a Sign in button and a Register link. View source shows a hidden field _csrf inside the form, added by th:action.
  3. Wrong password: 302 Location: /login?error, and the page shows “Invalid username or password.” in red.
  4. 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/dashboard
curl -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 record

Implement logout functionality in Spring Security.

Solution

Write in lab record

Steps

  1. The .logout(...) block in SecurityConfig (listed under Question 38) sets the URL, the redirect, session invalidation and cookie removal.
  2. dashboard.html already carries the Logout form. It must be a form with method="post"; a plain link does not work.
  3. Log in, click Logout. The browser lands on /login?logout with the green “You have been logged out.” line.
  4. Press the browser Back button: the dashboard does not reappear; /dashboard redirects to /login because 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/dashboard
HTTP/1.1 302
Location: http://localhost:8080/login

A 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 record

Create 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 record

Steps

  1. Add spring-boot-starter-validation to pom.xml and run Maven, Update Project.
  2. Add RegistrationForm and RegistrationController in in.ignou.admission.web, and register.html in templates.
  3. /register is already in the permitAll() list of SecurityConfig; authenticationManager is already a bean there.
  4. Restart. Open /register, submit an empty form: three red messages. Submit ab as the username: the length message. Submit mismatched passwords: “Passwords do not match”. Submit admin: “Username is already taken”.
  5. Submit a valid form (ravi_k, ravi123, ravi123): the browser lands on /dashboard showing “Welcome, ravi_k.” without visiting the login page.
  6. 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.

pom.xml fragmentxml
<!-- Session 9, Q40: Bean Validation (@NotBlank, @Size, @Email). Add inside <dependencies>. -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
RegistrationForm.javajava
// 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; }
}
RegistrationController.javajava
// 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";
    }
}
register.htmlhtml
<!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:

InputMessage shown
empty usernameUsername is required
abUsername must be 4 to 50 characters
ravi k (space)Letters, digits and underscore only
adminUsername is already taken
password 123Password must be 6 to 40 characters
confirm differsPasswords 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'"' -f3
Q7pM2...
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/dashboard
curl -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 a UserDetails with 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; matches re-reads the salt.
  • Q: Which part of /login is our code and which is Spring’s? A: The GET page is ours; the POST is handled by UsernamePasswordAuthenticationFilter.
  • Q: Why must the logout button be a POST form? A: CSRF protection is on, so LogoutFilter only accepts POST with a valid token.
  • Q: Why must BindingResult follow the @Valid parameter directly? A: Spring binds errors to the immediately preceding model attribute; otherwise it throws MethodArgumentNotValidException.
  • Q: Why is confirmPassword in 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; encode produces 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 user and pass; Spring reads username and password and every login fails with “Bad credentials”.
  • Writing the login form with action="/login" instead of th: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; @Valid compiles but no constraint runs and empty forms are saved.
  • Setting the SecurityContextHolder but not saving the context to the session, so the auto-login is lost on the very next request.

Session Summary

Write in lab record
  • User entity, UserRepository, AppUserDetailsService, DataSeeder and the generated users table with one BCrypt row
  • SecurityConfig with PasswordEncoder, AuthenticationManager and the SecurityFilterChain (custom login, logout, basic auth, CSRF exclusions for the API)
  • PageController, login.html and dashboard.html with the browser flow and the curl transcript
  • The logout block, the POST form and the transcript showing the 302 to /login?logout and the cleared cookie
  • RegistrationForm, RegistrationController, register.html, the validation message table and the auto-login transcript
Navigation

Type to search…

↑↓ navigate↵ selectEsc close