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