Skip to content

daggerok/spring-security-basics

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

spring-security-basics CI

Learn Spring Security by baby steps from zero to pro! (Status: IN PROGRESS)

Table of Content

step: 0

let's use simple spring boot web app without security at all!

application

use needed dependencies in pom.xml file:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
</dependencies>

add in Application.java file controller for index page:

@Controller
class IndexPage {

  @GetMapping("/")
  String index() {
    return "index.html";
  }
}

do not forget about src/main/resources/static/index.html template file:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>spring-security baby-steps</title>
</head>
<body>
<h1>Hello!</h1>
</body>
</html>

finally, to gracefully shutdown application under test on CI builds, add actuator dependency:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>
</dependencies>

with according configurations in application.yaml file:

spring:
  output:
    ansi:
      enabled: always
---
spring:
  profiles: ci
management:
  endpoint:
    shutdown:
      enabled: true
  endpoints:
    web:
      exposure:
        include: >
          shutdown

so, you can start application which is supports shutdown, like so:

java -jar /path/to/jar --spring.profiles.active=ci

test application

use required dependencies:

<dependencies>
  <dependency>
    <groupId>com.codeborne</groupId>
    <artifactId>selenide</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

implement Selenide test:

@Log4j2
@AllArgsConstructor
class ApplicationTest extends AbstractTest {

  @Test
  void test() {
    open("http://127.0.0.1:8080"); // open home page...
    var h1 = $("h1");              // find there <h1> tag...
    log.info("h1 html: {}", h1);
    h1.shouldBe(exist, visible)    // element should be inside DOM
      .shouldHave(text("hello"));  // textContent of the tag should
                                   // contains expected content...
  }
}

see sources for implementation details.

build, run test and cleanup:

./mvnw -f step-0-application-without-security
java -jar ./step-0-application-without-security/target/*jar --spring.profiles.active=ci &
./mvnw -Dgroups=e2e -f step-0-test-application-without-security
http post :8080/actuator/shutdown

step: 1

in this step we are going to implement simple authentication. it's mean everyone who logged in, can access all available resources.

application

add required dependencies:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
</dependencies>

update application.yaml configuration with desired user password:

spring:
  security:
    user:
      password: pwd

tune little bit security config to bein able shutdown application with POST: we have to permit it and disable CSRF:

@EnableWebSecurity
class MyWebSecurity extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
          .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
          .anyRequest().authenticated()
        .and()
          .csrf().disable()
        .formLogin()
    ;
  }
}

test application

now, let's update test according to configured security as follows:

@Log4j2
@AllArgsConstructor
class ApplicationTest extends AbstractTest {

  @Test
  void test() {
    open("http://127.0.0.1:8080");
    // we should be redirected to login page, so lets authenticate!
    $("#username").setValue("user");
    $("#password").setValue("pwd").submit();
    // everything else is with no changes...
    var h1 = $("h1");
    log.info("h1 html: {}", h1);
    h1.shouldBe(exist, visible)
      .shouldHave(text("hello"));
  }
}

build, run test and cleanup:

./mvnw -f step-0-application-without-security
SPRING_PROFILES_ACTIVE=ci java -jar ./step-0-application-without-security/target/*jar &
./mvnw -Dgroups=e2e -f step-0-test-application-without-security
http post :8080/actuator/shutdown

step: 2

let's add few users for authorization:

@EnableWebSecurity
class MyWebSecurity extends WebSecurityConfigurerAdapter {

  @Bean
  PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
          .withUser("user")
            .password(passwordEncoder().encode("password"))
            .roles("USER")
            .and()
          .withUser("admin")
            .password(passwordEncoder().encode("admin"))
            .roles("USER", "ADMIN")
        ;
  }

  // ...
}

now we can authenticate with users/password or admin/admin

step: 3

now let's add authorization, so we can distinguish that different users have access to some resources where others are not!

application

in next configuration access to /admin path:

@Controller
class AdminPage {

  @GetMapping("admin")
  String index() {
    return "admin/index.html";
  }
}

add admin/index.html file:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Admin Page | spring-security baby-steps</title>
</head>
<body>
<h2>Administration page</h2>
</body>
</html>

we can allow to users with admin role:

@EnableWebSecurity
class MyWebSecurity extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
          .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
          .antMatchers("/admin/**").hasRole("ADMIN")
          .anyRequest().authenticated()
        .and()
          .csrf().disable()
        .formLogin()
    ;
  }
}

test application

@Value
@ConstructorBinding
@ConfigurationProperties("test-application-props")
class TestApplicationProps {

  String baseUrl;
  User admin;
  User user;

  @Value
  @ConstructorBinding
  static class User {
    String username;
    String password;
  }
}

@Log4j2
@Tag("e2e")
@AllArgsConstructor
@SpringBootTest(properties = {
    "test-application-props.user.username=user",
    "test-application-props.user.password=password",
    "test-application-props.admin.username=admin",
    "test-application-props.admin.password=admin",
    "test-application-props.base-url=http://127.0.0.1:8080",
})
class ApplicationTest {

  ApplicationContext context;

  @Test
  void admin_should_authorize() {
    var props = context.getBean(TestApplicationProps.class);
    open(String.format("%s/admin", props.getBaseUrl()));
    $("#username").setValue(props.getAdmin().getUsername());
    $("#password").setValue(props.getAdmin().getPassword()).submit();

    var h2 = $("h2");
    log.info("h2 html: {}", h2);
    h2.shouldBe(exist, visible)
      .shouldHave(text("administration"));
  }

  @Test
  void test_forbidden_403() {
    var props = context.getBean(TestApplicationProps.class);
    open(String.format("%s/admin", props.getBaseUrl()));
    $("#username").setValue(props.getUser().getUsername());
    $("#password").setValue(props.getUser().getPassword()).submit();
    $(withText("403")).shouldBe(exist, visible);
    $(withText("Forbidden")).shouldBe(exist, visible);
  }

  @AfterEach
  void after() {
    closeWebDriver();
  }
}

step: 4

let's try use Spring Security together with JavaEE! NOTE: use spring version 4.x, not 5!

in this step we will configure JavaEE app for next sets of security rules:

allowed for all: /, /favicon.ico, /api/health, /login, /logout allowed for admins only: /admin all other paths allowed only for authenticated users.

application

dependencies:

<dependencies>
  <dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-taglibs</artifactId>
  </dependency>
</dependencies>

JAX-RS application:

@ApplicationScoped
@ApplicationPath("api")
public class Config extends Application { }

@Path("")
@RequestScoped
@Produces(APPLICATION_JSON)
public class HealthResource {

  @GET
  @Path("health")
  public JsonObject hello() {
    return Json.createObjectBuilder()
               .add("status", "UP")
               .build();
  }
}

@Path("v1")
@RequestScoped
@Produces(APPLICATION_JSON)
public class MyResource {

  @GET
  @Path("hello")
  public JsonObject hello() {
    return Json.createObjectBuilder()
               .add("hello", "world!")
               .build();
  }
}

Spring Security configuration:

@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // @formatter:off
    auth.inMemoryAuthentication()
          .withUser("user")
          .password("password")
          .roles("USER")
        .and()
          .withUser("admin")
          .password("admin")
          .roles("ADMIN")
    // @formatter:on
    ;
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // @formatter:off
    http.authorizeRequests()
          .antMatchers("/", "/favicon.ico", "/api/health").permitAll()
          .antMatchers("/admin/**").hasRole("ADMIN")
          .anyRequest().authenticated()
        .and()
          .formLogin()
        .and()
          .logout()
            .logoutSuccessUrl("/")
            .clearAuthentication(true)
            .invalidateHttpSession(true)
            .deleteCookies("JSESSIONID")
        .and()
          .csrf().disable()
        .sessionManagement()
          .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
    // @formatter:on
    ;
  }
}

public class SecurityWebApplicationInitializer extends AbstractSecurityWebApplicationInitializer {

  public SecurityWebApplicationInitializer() {
    super(SpringSecurityConfig.class);
  }
}

add src/main/resources/META-INF/beans.xml file:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
       bean-discovery-mode="annotated">
</beans>

finally, add HTML pages:

file src/main/webapp/index.html:

<!doctype html>
<html lang="en">
<head>
  <title>Hello!</title>
</head>
<body>
  <h1>Hello!</h1>
</body>
</html>

file src/main/webapp/admin/index.html:

<!doctype html>
<html lang="en">
<head>
  <title>Admin</title>
</head>
<body>
  <h1>Admin page</h1>
</body>
</html>

test application

./mvnw -f step-4-java-ee-jaxrs-jboss-spring-security
./mvnw -f step-4-java-ee-jaxrs-jboss-spring-security docker:build docker:start
./mvnw -f step-4-test-java-ee-jboss-spring-security -Dgroups=e2e
./mvnw -f step-4-java-ee-jaxrs-jboss-spring-security docker:stop docker:remove

step: 5.1

let's use jdbc database as users / roles store.

security config:

@EnableWebSecurity
@RequiredArgsConstructor
class MyWebSecurity extends WebSecurityConfigurerAdapter {

  final DataSource dataSource;

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.jdbcAuthentication()
          .dataSource(dataSource)
          .usersByUsernameQuery(
             " select sec_username, sec_password, sec_enabled " +
             " from sec_users where sec_username=?            "
          )
          .authoritiesByUsernameQuery(
            " select sec_username, sec_authority        " +
            " from sec_authorities where sec_username=? "
          );
    ;
  }

  // ...
}

sql schema and data:

drop index if exists sec_authorities_idx;
drop table if exists sec_authorities;
drop table if exists sec_users;
drop schema if exists "public";

create schema "public";

create table sec_users (
  sec_username varchar(255) not null primary key,
  sec_password varchar(1024) not null,
  sec_enabled boolean not null
);

create table sec_authorities (
  sec_username varchar(255) not null,
  sec_authority varchar(255) not null,
  constraint sec_authorities_fk
    foreign key (sec_username)
      references sec_users (sec_username)
);

create unique index sec_authorities_idx
  on sec_authorities (sec_username, sec_authority);

insert into sec_users (sec_username, sec_password, sec_enabled)
values ('user', '{bcrypt}$2a$10$OlBp2JOK0/8xDjiVqh4OYOggr3tHTKfBcv82dso4fsnUPo66f5Ury', true),  -- password
       ('admin', '{bcrypt}$2a$10$OKPak8tw3jYSyqil/eNKz.U1nF/HtabOotUqi2ceeLuWdBsejH9yS', true); -- admin
insert into sec_authorities (sec_username, sec_authority)
values ('user', 'ROLE_USER'),
       ('admin', 'ROLE_ADMIN');

testing:

./mvnw -f step-5-jdbc-authentication clean package spring-boot:build-image docker-compose:up
while ! [[ `curl -s -o /dev/null -w "%{http_code}" 0:8080/actuator/health` -eq 200 ]] ; do sleep 1s ; echo -n '.' ; done
./mvnw -f step-5-test-jdbc -Dgroups=e2e 
./mvnw -f step-5-jdbc-authentication docker-compose:down

step: 5.2

let's use spring-data-jdbc database as users / roles store.

add security entity, repository and service:

@With
@Value
@Table("sec_users")
class Security {

  @Id
  @Column("sec_username") String username;
  @Column("sec_password") String password;
  @Column("sec_enabled") boolean active;
  @Column("sec_authority") String authority;

  public UserDetails toUserDetails() {
    return User.builder()
               .username(username)
               .password(password)
               .disabled(!active)
               .accountExpired(!active)
               .credentialsExpired(!active)
               .authorities(AuthorityUtils.createAuthorityList(authority))
               .build();
  }
}

interface SecurityRepository extends CrudRepository<Security, String> {

  @Query("select * from sec_users where sec_username = :username limit 1")
  Optional<Security> findFirstByUsername(@Param("username") String username);
}

@Service
@RequiredArgsConstructor
class SecurityService implements UserDetailsService {

  final SecurityRepository securityRepository;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    return securityRepository.findFirstByUsername(username)
                             .map(Security::toUserDetails)
                             .orElseThrow(() -> new UsernameNotFoundException(
                                 String.format("User %s not found.", username)));
  }
}

security config:

@EnableWebSecurity
@RequiredArgsConstructor
class MyWebSecurity extends WebSecurityConfigurerAdapter {

  final SecurityService securityService;

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(securityService);
  }

  // ...
}

sql schema and data:

drop index if exists sec_users_authorities_idx;
drop table if exists sec_users;
drop schema if exists "public";

create schema "public";

create table sec_users (
  sec_username varchar(255) not null primary key,
  sec_password varchar(1024) not null,
  sec_enabled boolean not null,
  sec_authority varchar(255) not null
);

create unique index sec_users_authorities_idx
  on sec_users (sec_username, sec_authority);

insert into sec_users (sec_username, sec_password, sec_enabled, sec_authority)
values ('user', '{bcrypt}$2a$10$OlBp2JOK0/8xDjiVqh4OYOggr3tHTKfBcv82dso4fsnUPo66f5Ury', true, 'ROLE_USER')
,      ('admin', '{bcrypt}$2a$10$OKPak8tw3jYSyqil/eNKz.U1nF/HtabOotUqi2ceeLuWdBsejH9yS', true, 'ROLE_ADMIN')
;

testing:

./mvnw -f step-5-spring-data-jdbc-authentication clean package spring-boot:build-image docker-compose:up
./mvnw -f step-5-test-jdbc -Dgroups=e2e 
./mvnw -f step-5-spring-data-jdbc-authentication docker-compose:down

step: 5.3

let's use spring-data-jpa this time.

required changes:

@Data
@Entity
@Setter(PROTECTED)
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor(staticName = "of")
@Table(name = "sec_users")
class Security {

  @Id
  @Column(nullable = false, name = "sec_username")
  private String username;

  @Column(nullable = false, name = "sec_password")
  private String password;

  @Column(nullable = false, name = "sec_enabled")
  private boolean active;

  @Column(nullable = false, name = "sec_authority")
  private String authority;

  public UserDetails toUserDetails() {
    return User.builder()
               .username(username)
               .password(password)
               .disabled(!active)
               .accountExpired(!active)
               .credentialsExpired(!active)
               .authorities(AuthorityUtils.createAuthorityList(authority))
               .build();
  }
}

interface SecurityRepository extends CrudRepository<Security, String> {

  @Query
  Optional<Security> findFirstByUsername(@Param("username") String username);
}

@Service
@RequiredArgsConstructor
class SecurityService implements UserDetailsService {

  final SecurityRepository securityRepository;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    return securityRepository.findFirstByUsername(username)
                             .map(Security::toUserDetails)
                             .orElseThrow(() -> new UsernameNotFoundException(
                                 String.format("User %s not found.", username)));
  }
}

@EnableWebSecurity
@RequiredArgsConstructor
class MyWebSecurity extends WebSecurityConfigurerAdapter {

  final SecurityService securityService;

  @Bean
  PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(securityService);
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
          .requestMatchers(EndpointRequest.to("health", "shutdown")).permitAll()
          .antMatchers("/", "/favicon.ico", "/assets/**").permitAll()
          .antMatchers("/admin/**").hasRole("ADMIN")
          .anyRequest().authenticated()
        .and()
          .csrf().disable()
        .formLogin()
          .and()
        .httpBasic()
    ;
  }
}

_application.yaml` file:

spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://${POSTGRES_HOST:127.0.0.1}:${POSTGRES_PORT:5432}/${POSTGRES_DB:postgres}
    username: ${POSTGRES_USER:postgres}
    password: ${POSTGRES_PASSWORD:postgres}
  flyway:
    enabled: true
  jpa:
    database: postgresql
    generate-ddl: false
    show-sql: true
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        temp:
          use_jdbc_metadata_defaults: false

db/migration scripts:

create table sec_users (
  sec_username varchar(255) not null primary key,
  sec_password varchar(1024) not null,
  sec_enabled boolean not null,
  sec_authority varchar(255) not null
)
;
create unique index sec_users_authorities_idx
  on sec_users (sec_username, sec_authority)
;
insert into sec_users (sec_username, sec_password, sec_enabled, sec_authority)
values ('user', '{bcrypt}$2a$10$OlBp2JOK0/8xDjiVqh4OYOggr3tHTKfBcv82dso4fsnUPo66f5Ury', true, 'ROLE_USER')
,      ('admin', '{bcrypt}$2a$10$OKPak8tw3jYSyqil/eNKz.U1nF/HtabOotUqi2ceeLuWdBsejH9yS', true, 'ROLE_ADMIN')
;

testing:

# docker-compose -f step-5-spring-data-jpa-authentication/docker-compose.yaml up postgres
./mvnw -f step-5-spring-data-jpa-authentication clean package spring-boot:build-image docker-compose:up
./mvnw -f step-5-test-jdbc -Dgroups=e2e 
./mvnw -f step-5-spring-data-jpa-authentication docker-compose:down

maven

we will be releasing after each important step! so it will be easy simply checkout needed version from git tag.

release version without maven-release-plugin (when you aren't using *-SNAPSHOT version for development):

currentVersion=`./mvnw -q --non-recursive exec:exec -Dexec.executable=echo -Dexec.args='${project.version}'`
git tag "v$currentVersion"

./mvnw build-helper:parse-version -DgenerateBackupPoms=false -DgenerateBackupPoms=false versions:set \
  -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.nextIncrementalVersion} \
  -f step-4-java-ee-jaxrs-jboss-spring-security
./mvnw build-helper:parse-version -DgenerateBackupPoms=false versions:set -DgenerateBackupPoms=false \
  -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.nextIncrementalVersion}
nextVersion=`./mvnw -q --non-recursive exec:exec -Dexec.executable=echo -Dexec.args='${project.version}'`

git add . ; git commit -am "v$currentVersion release." ; git push --tags

increment version:

1.1.1?->1.1.2
./mvnw build-helper:parse-version -DgenerateBackupPoms=false versions:set -DgenerateBackupPoms=false -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.nextIncrementalVersion}

current release version:

# 1.2.3-SNAPSHOT -> 1.2.3
./mvnw build-helper:parse-version -DgenerateBackupPoms=false versions:set -DgenerateBackupPoms=false -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.incrementalVersion}

next snapshot version:

# 1.2.3? -> 1.2.4-SNAPSHOT
./mvnw build-helper:parse-version -DgenerateBackupPoms=false versions:set -DgenerateBackupPoms=false -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.nextIncrementalVersion}-SNAPSHOT

resources