Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

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.

Thanks for reading!

Donate to the site — we work hard for you.