August 29, 2023

Spring Boot 3 with Basic Authentication and JDBC Password Storage

Create new Maven Project

Create new project with Spring Initializr (https://start.spring.io/) and add dependency: Spring Data JDBC and MySQL

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>

Add Spring Data JDBC properties src/main/resources/application.properties

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.data

    spring.datasource.url=jdbc:mysql://localhost:3306/mydb
    spring.datasource.username=user
    spring.datasource.password=changeit

Start a local mysql server with a container image.

https://catalog.redhat.com/software/containers/rhel9/mysql-80/61a60915c17162a20c1c6a34

https://hub.docker.com/_/mysql

$ podman run -d --name mysqld \
    -e MYSQL_USER=user \
    -e MYSQL_PASSWORD=changeit \
    -e MYSQL_DATABASE=mydb \
    -e MYSQL_ROOT_PASSWORD=changeit \
    -p 3306:3306 \
    docker.io/library/mysql:8.0

$ podman logs --follow mysqld

Create and populate DB. Use below test method to generated hashed password.

        @Test
        public void test() throws Exception {
            PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
            System.out.println(passwordEncoder.encode("changeit"));
        }

Now connect to mysql container interactive and execute sql commands to creae tables and populate them with data.

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/jdbc.html

$ podman exec -it mysqld /bin/bash

bash-4.4# mysql -u root -p mydb

create table users(username varchar(50) not null primary key, password varchar(500) not null, enabled boolean not null);
create table authorities (username varchar(50) not null, authority varchar(50) not null, constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username, authority);

INSERT INTO users (username, password, enabled) VALUES ('john', '{bcrypt}$2a$10$aohc8ylx1YcZx6p/L2BRv.I4oQfDin9Ed2CNTy0ZXQ3ZpdiMalLp6', true);

INSERT INTO authorities (username, authority) VALUES ('john', 'ROLE_USER');
INSERT INTO authorities (username, authority) VALUES ('john', 'ROLE_ADMIN');

Application

package se.mkk.springboot3basicjdbc;

import javax.sql.DataSource;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter.Directive;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class BasicJdbcSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http //
                .sessionManagement(
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
                        // https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.server
                        Customizer.withDefaults())
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests //
                        .requestMatchers("/login", "/logout").permitAll() //
                        .anyRequest().authenticated()) //
                .httpBasic( //
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html
                        Customizer.withDefaults()) //
                .csrf(csrf -> csrf //
                        // https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-token-repository-cookie
                        .ignoringRequestMatchers("/logout", "/api"))
                .logout(logout -> logout //
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/logout.html#clear-all-site-data
                        .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(Directive.ALL))));
        return http.build();
    }

    // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/jdbc.html
    @Bean
    public UserDetailsManager jdbcUserDetailsManager(DataSource dataSource) {
        JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
        return users;
    }
}
package se.mkk.springboot3basicjdbc;

import java.security.Principal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "http://localhost:4200")
public class UserController {

    @GetMapping
    public Map<String, String> getUser(HttpServletRequest request, HttpSession session, Principal principal) {
        Map<String, String> rtn = new LinkedHashMap<>();
        rtn.put("session_getMaxInactiveInterval_sec", session.getMaxInactiveInterval() + "s");
        rtn.put("session_getLastAccessedTime",
                new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").format(new Date(session.getLastAccessedTime())));
        rtn.put("request_getRemoteUser", request.getRemoteUser());
        rtn.put("request_isUserInRole_USER", Boolean.toString(request.isUserInRole("USER")));
        rtn.put("request_getUserPrincipal_getClass", request.getUserPrincipal().getClass().getName());
        rtn.put("principal_getClass_getName", principal.getClass().getName());
        rtn.put("principal_getName", principal.getName());
        if (principal instanceof Authentication authentication) {
            List<String> authorities = authentication.getAuthorities().stream()
                    .map(grantedAuthority -> grantedAuthority.getAuthority()).toList();
            rtn.put("JwtAuthenticationToken.getAuthorities()", authorities.toString());
        }
        return rtn;
    }
}

Test

$ curl -v -X GET -u "john:changeit" http://localhost:8080/api/users 
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
* Server auth using Basic with user 'john'
> GET /api/users HTTP/1.1
> Host: localhost:8080
> Authorization: Basic am9objpjaGFuZ2VpdA==
> User-Agent: curl/7.85.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Set-Cookie: JSESSIONID=56FAD7EB738EB12B558F02EBC04122BE; Max-Age=180; Expires=Tue, 29 Aug 2023 21:02:41 GMT; Path=/; Secure; HttpOnly; SameSite=Strict
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Tue, 29 Aug 2023 20:59:41 GMT
< 
* Connection #0 to host localhost left intact
{"session_getMaxInactiveInterval_sec":"180s","session_getLastAccessedTime":"2023-08-29 22:59:41 +0200","request_getRemoteUser":"john","request_isUserInRole_USER":"true","request_getUserPrincipal_getClass":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principal_getClass_getName":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principal_getName":"john","JwtAuthenticationToken.getAuthorities()":"[ROLE_ADMIN, ROLE_USER]"}

$ curl -s -X GET -u "john:changeit" http://localhost:8080/api/users | jq -r
{
  "session_getMaxInactiveInterval_sec": "180s",
  "session_getLastAccessedTime": "2023-08-29 22:58:32 +0200",
  "request_getRemoteUser": "john",
  "request_isUserInRole_USER": "true",
  "request_getUserPrincipal_getClass": "org.springframework.security.authentication.UsernamePasswordAuthenticationToken",
  "principal_getClass_getName": "org.springframework.security.authentication.UsernamePasswordAuthenticationToken",
  "principal_getName": "john",
  "JwtAuthenticationToken.getAuthorities()": "[ROLE_ADMIN, ROLE_USER]"
}

No comments: