Site login: a form and a session
The API checks the password on every request, but that is awkward for a person in a browser. Sites use form login: the user enters the name and password once, the server remembers them in a session, and the browser keeps the session id in the JSESSIONID cookie. For the site we use sessions rather than JWT tokens: they are built into Spring Security, and they are enough for a site with server-rendered pages. JWT is needed when a separate application — a mobile app, for example — calls the API; that is the next lesson.
A second chain
// src/main/java/com/example/blog/SecurityConfig.java
@Bean
@Order(2)
public SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/posts/new").authenticated()
.requestMatchers(HttpMethod.GET, "/", "/posts/*", "/css/**").permitAll()
.requestMatchers("/error").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/", false)
.permitAll())
.logout(logout -> logout.logoutSuccessUrl("/"));
return http.build();
}This chain has no securityMatcher, so it takes everything the API chain with @Order(1) did not. The /posts/new line comes before /posts/*: otherwise the new-post form would fall under "open to everyone". CSRF stays on here — browser forms need it.
The login page
// src/main/java/com/example/blog/PageController.java
@GetMapping("/login")
public String login() {
return "login";
}<!-- src/main/resources/templates/login.html -->
<p class="error" th:if="${param.error}">Wrong username or password.</p>
<form th:action="@{/login}" method="post">
<label>Username <input type="text" name="username" autofocus></label>
<label>Password <input type="password" name="password"></label>
<button type="submit">Sign in</button>
</form>The fields must be called username and password — that is what Spring Security expects. It also handles POST /login itself; no method is needed. With a wrong password the browser returns to /login?error, and param.error shows the message. Thanks to th:action, Thymeleaf adds a hidden _csrf field with the token to the form.
Who is logged in
To show the name and a logout button in the header, add the Thymeleaf extras for Spring Security:
<!-- pom.xml -->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency><!-- src/main/resources/templates/fragments/layout.html -->
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<header th:fragment="header">
<a th:href="@{/}">Blog</a>
<a th:href="@{/posts/new}">New post</a>
<span sec:authorize="isAuthenticated()">
<span sec:authentication="name">anna</span>
<form th:action="@{/logout}" method="post" style="display: inline">
<button type="submit">Sign out</button>
</form>
</span>
<a sec:authorize="isAnonymous()" th:href="@{/login}">Sign in</a>
</header>Logging out is a POST /logout with a CSRF token, so it is a form, not a link. In the form controller the user comes from a Principal parameter: postService.create(form, principal.getName()), and the author list can be removed from the form.
Checking it
Open http://localhost:8080/posts/new — the browser goes to /login. After signing in as Anna the header shows anna and a "Sign out" button, and the form publishes a post in her name. The same path with curl looks like this: the response to a correct login is
HTTP/1.1 302
Set-Cookie: JSESSIONID=E631F5BDDBCB28E0E6DF15E25E8E6754; Path=/; HttpOnly
Location: http://localhost:8080/and with a wrong password Location leads to /login?error. Submitting the post form without the _csrf field gets 403.
Common mistake
If you do not open the /error address, API clients start getting a redirect to the login form instead of 401:
HTTP/1.1 302
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"The API chain refuses, Spring shows the /error page, and that page is handled by the site chain, which demands a login. The .requestMatchers("/error").permitAll() line brings back the correct 401.