/spring-jwt-secured-apps

From zero to JWT hero in Spring Boot Servlet app

Primary LanguageJavaMIT LicenseMIT

spring-jwt-secured-apps CI

From zero to JWT hero in Spring Servlet applications!

Table of Content

step: 0

let's use simple spring boot web app with pom.xml file:

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

with SpringJwtSecuredAppsApplication.java file:

@Controller
class IndexPage {

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

@RestController
class HelloResource {

  @GetMapping("/api/hello")
  Map<String, String> hello() {
    return Map.of("Hello", "world");
  }
}

with src/main/resources/static/index.html file:

<!doctype html>
<html lang="en">
<head>
  <title>JWT</title>
</head>
<body>
<h1>Hello</h1>
<ul id="app"></ul>
<script>
  document.addEventListener('DOMContentLoaded', onDOMContentLoaded, false);

  function onDOMContentLoaded() {
    const headers = { 'Content-Type': 'application/json' };

    let options = { method: 'GET', headers, };
    fetch('/api/hello', options)
      .then(response => response.json())
      .then(json => {
        console.log('json', json);
        const textNode = document.createTextNode(JSON.stringify(json));
        document.querySelector('#app').prepend(textNode);
      })
    ;
  }
</script>
</body>
</html>

with that we can query with no security at all:

http :8080
http :8080/api/hello

step: 1

let's use default spring-security:

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

user has generated password (initially taken from server logs), so let's configure it in application.properties file:

spring.security.user.password=80427fb5-888f-4669-83c0-893ca655a82e

with that we can query like so:

http -a user:80427fb5-888f-4669-83c0-893ca655a82e :8080
http -a user:80427fb5-888f-4669-83c0-893ca655a82e :8080/api/hello

step: 2

create custom security config:

@Configuration
@RequiredArgsConstructor
class MyWebSecurity extends WebSecurityConfigurerAdapter {

  final MyUserDetailsService myUserDetailsService;

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

where UserDetailsService implemented as follows:

@Service
@RequiredArgsConstructor
class MyUserDetailsService implements UserDetailsService {

  final PasswordEncoder passwordEncoder;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    return Optional.ofNullable(username)
                   .filter(u -> u.contains("max") || u.contains("dag"))
                   .map(u -> new User(username,
                                      passwordEncoder.encode(username),
                                      AuthorityUtils.createAuthorityList("USER")))
                   .orElseThrow(() -> new UsernameNotFoundException(String.format("User %s not found.", username)));
  }
}

also, we need PasswordEncoder in context:

@Configuration
class MyPasswordEncoderConfig {

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

with that, we can use username and password, which must be the same and must contain max or dag words:

http -a max:max get :8080
http -a daggerok:daggerok get :8080/api/hello

step: 3

first, let's add required dependencies:

  <dependencies>
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt</artifactId>
    </dependency>
  </dependencies>

update backend

implement auth rest resources:

@RestController
@RequiredArgsConstructor
class JwtResource {

  final JwtService jwtService;
  final UserDetailsService userDetailsService;
  final AuthenticationManager authenticationManager;

  @PostMapping("/api/auth")
  AuthenticationResponse authenticate(@RequestBody AuthenticationRequest request) {
    var token = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
    var authentication = authenticationManager.authenticate(token);
    var userDetails = userDetailsService.loadUserByUsername(request.getUsername());
    var jwtToken = jwtService.generateToken(userDetails);
    return new AuthenticationResponse(jwtToken);
  }
}

where:

JwtService

@Service
class JwtService {
  String generateToken(UserDetails userDetails) {
    /* Skipped jwt infrastructure logic... See sources for details */
  }
}

AuthenticationManager

class MyWebSecurity extends WebSecurityConfigurerAdapter {
  
  @Override
  @Bean // Requires to being able to inject AuthenticationManager bean in our AuthResource.
  public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
  }

  /**
   * Requires to:
   * - post authentication without CSRF protection
   * - permit all requests for index page and /api/auth auth resource path
   */
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
          .mvcMatchers(HttpMethod.GET, "/").permitAll()
          .mvcMatchers(HttpMethod.POST, "/api/auth").permitAll()
          .anyRequest().fullyAuthenticated()//.authenticated()//
        .and()
          .csrf().disable()
        // .formLogin()
    ;
  }

  // ...
}

update frontend

  options = {
    method: 'POST', headers,
    body: JSON.stringify({ username: 'dag', password: 'dag' }),
  };

  fetch('/api/auth', options)
    .catch(errorHandler)
    .then(response => response.json())
    .then(json => {
      console.log('auth json', json);
      const result = JSON.stringify(json);
      const textNode = document.createTextNode(result);
      document.querySelector('#app').prepend(textNode);
    })
  ;

  function errorHandler(reason) {
    console.log(reason);
  }

test

with that, open http://127.0.0.1:8080 page, or use username and password, which must be the same and must contain max or dag words in your AuthenticationRequest:

http post :8080/api/auth username=dag password=dag

step: 4

let's now implement request filter interceptor, which is going to parse authorization header for Bearer token and authorizing spring security context accordingly to its validity:

JwtRequestFilter

@Component
@RequiredArgsConstructor
class JwtRequestFilter extends OncePerRequestFilter {

  final JwtService jwtService;
  final UserDetailsService userDetailsService;

  @Override
  protected void doFilterInternal(HttpServletRequest httpServletRequest,
                                  HttpServletResponse httpServletResponse,
                                  FilterChain filterChain) throws ServletException, IOException {

    var prefix = "Bearer ";
    var authorizationHeader = httpServletRequest.getHeader(HttpHeaders.AUTHORIZATION);

    Optional.ofNullable(authorizationHeader).ifPresent(ah -> {

      var parts = ah.split(prefix);
      if (parts.length < 2) return;

      var accessToken = parts[1].trim();
      Optional.of(accessToken). filter(Predicate.not(String::isBlank)).ifPresent(at -> {

        if (jwtService.isTokenExpire(at)) return;

        var username = jwtService.extractUsername(at);
        var userDetails = userDetailsService.loadUserByUsername(username);
        if (!jwtService.validateToken(at, userDetails)) return;

        var authentication = new UsernamePasswordAuthenticationToken(username, null, userDetails.getAuthorities());
        var details = new WebAuthenticationDetailsSource().buildDetails(httpServletRequest);

        authentication.setDetails(details);
        SecurityContextHolder.getContext().setAuthentication(authentication);
      });
    });

    filterChain.doFilter(httpServletRequest, httpServletResponse);
  }
}

finally, update fronted to leverage localStorage as accessToken store:

function headersWithAuth() {
  const accessToken = localStorage.getItem('accessToken');
  return !accessToken ? headers : Object.assign({}, headers,
    { Authorization: 'Bearer ' + accessToken });
}

function auth() {
  const options = {
    method: 'POST', headers: headersWithAuth(),
    body: JSON.stringify({ username: 'max', password: 'max' }),
  };
  fetch('/api/auth', options)
    .then(response => response.json())
    .then(json => {
      if (json.accessToken) localStorage.setItem('accessToken', json.accessToken);
    })
  ;
}

function api() {
  const options = { method: 'GET', headers: headersWithAuth() };
  fetch('/api/hello', options)
    .then(response => response.json())
    .then(json => {
      if (json.status && json.status >= 400) {
        auth();
        return;
      }
      const result = JSON.stringify(json);
      const textNode = document.createTextNode(result);
      const div = document.createElement('div');
      div.append(textNode)
      document.querySelector('#app').prepend(div);
    })
  ;
}

auth();
setInterval(api, 1111);

with that, we can verify on http://127.0.0.1:8080 page how frontend applications is automatically doing authentication and accessing rest api!

step: 5

just adding JwtRequestFilter was not enough. last missing peace is spring by default managing state, so in certain cases JWT expiration may not work completely. to fix that problem we should configure our spring security config accordingly:

MyWebSecurity

@Configuration
@RequiredArgsConstructor
class MyWebSecurity extends WebSecurityConfigurerAdapter {

  final JwtRequestFilter jwtRequestFilter;

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // @formatter:off
    http.authorizeRequests()
          .mvcMatchers(HttpMethod.GET, "/").permitAll()
          .mvcMatchers(HttpMethod.POST, "/api/auth").permitAll()
          .anyRequest().authenticated()//.fullyAuthenticated()//
        .and()
          .csrf().disable()
        .sessionManagement()
          .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
          .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
    // @formatter:on
    ;
  }

  // ...
}

now run application and open http://127.0.0.1:8080/ page to verify how token will expire and requested new one. done!

maven

we will be releasing after each important step! so it will be easy simply checkout needed version from git tag. release current version using maven-release-plugin (when you are using *-SNAPSHOT version for development):

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

./mvnw build-helper:parse-version -DgenerateBackupPoms=false versions:set \
    -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.nextIncrementalVersion}
developmentVersion=`./mvnw -q --non-recursive exec:exec -Dexec.executable=echo -Dexec.args='${project.version}'`
./mvnw build-helper:parse-version -DgenerateBackupPoms=false versions:set -DnewVersion="$currentVersion"

./mvnw clean release:prepare release:perform \
    -B -DgenerateReleasePoms=false -DgenerateBackupPoms=false \
    -DreleaseVersion="$currentVersion" -DdevelopmentVersion="$developmentVersion"

resources