[JAVA] Implement a simple Rest API with Spring Security & JWT with Spring Boot 2.0

Overview

This article is the demo application used in the previous post "Implementing a simple Rest API with Spring Security with Spring Boot 2.0.1". Is an article that explains the changes when making JWT (Json Web Token) compatible. There are many detailed articles on the JWT specifications, so I won't cover them here.

The source code can be found at rubytomato / demo-security-jwt-spring2.

environment

reference

Demo application requirements

How to authenticate

This demo application authenticates with your email address and password. Specifically, a POST request for an email address and password is made to the login API, and if authentication is possible, an HTTP status 200 and JWT token will be returned. Subsequent authentication and authorization are performed by the JWT token set in the request header.

Security implementation (where changed due to JWT support)

Add dependency

I chose java-jwt from the libraries that handle multiple JWTs.

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.3.0</version>
</dependency>

Easy to use java-jwt

About the token payload

Some token payload (claim) identifiers are Registered Claim Names. Both are treated as optional, so whether to set them depends on the application to be used.

id name description
jti JWT ID JWT unique identifier
aud Audience JWT users
iss Issuer JWT issuer
sub Subject JWT subject.Unique or globally unique value within the context of the JWT issuer
iat Issued At JWT issuance time
nbf Not Before JWT validity period start time.Not available before this time
exp Expiration Time End time of JWT validity period.Not available after this time

Token generation

private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

public void build() {
     String secretKey = "secret";
     Date issuedAt = new Date(); 
     Date notBefore = new Date(issuedAt.getTime());
     Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);

     try {
         Algorithm algorithm = Algorithm.HMAC256(secretKey);
         String token = JWT.create()
             // registered claims
             //.withJWTId("jwtId")        //"jti" : JWT ID
             //.withAudience("audience")  //"aud" : Audience
             //.withIssuer("issuer")      //"iss" : Issuer
             .withSubject("test")         //"sub" : Subject
             .withIssuedAt(issuedAt)      //"iat" : Issued At
             .withNotBefore(notBefore)    //"nbf" : Not Before
             .withExpiresAt(expiresAt)    //"exp" : Expiration Time
             //private claims
             .withClaim("X-AUTHORITIES", "aaa")
             .withClaim("X-USERNAME", "bbb")
             .sign(algorithm);
         System.out.println("generate token : " + token);
     } catch (UnsupportedEncodingException e) {
         e.printStackTrace();
     }
}

The token generated by this setting is as follows.

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwibmJmIjoxNTIzNTA2NzQwLCJYLUFVVEhPUklUSUVTIjoiYWFhIiwiZXhwIjoxNTIzNTA3MzQwLCJpYXQiOjE1MjM1MDY3NDAsIlgtVVNFUk5BTUUiOiJiYmIifQ.KLwUQcuNEt7m1HAC6ZzzGtRjZ3a2kvY11732aP9dyDY

You can decode the tokens at the site JSON Web Tokens --jwt.io.

d.png

Token verification

public void verify() {
    String secretKey = "secret";
    String token = "";

    try {
        Algorithm algorithm = Algorithm.HMAC256(secretKey);
        JWTVerifier verifier = JWT.require(algorithm).build();

        DecodedJWT jwt = verifier.verify(token);

        // registered claims
        String subject = jwt.getSubject();
        Date issuedAt = jwt.getIssuedAt();
        Date notBefore = jwt.getNotBefore();
        Date expiresAt = jwt.getExpiresAt();
        System.out.println("subject : [" + subject + "] issuedAt : [" + issuedAt.toString() + "] notBefore : [" + notBefore.toString() + "] expiresAt : [" + expiresAt.toString() + "]");
        // subject : [test] issuedAt : [Thu Apr 12 13:19:00 JST 2018] notBefore : [Thu Apr 12 13:19:00 JST 2018] expiresAt : [Thu Apr 12 13:29:00 JST 2018]

        // private claims
        String authorities = jwt.getClaim("X-AUTHORITIES").asString();
        String username = jwt.getClaim("X-USERNAME").asString();
        System.out.println("private claim  X-AUTHORITIES : [" + authorities + "] X-USERNAME : [" + username + "]");
        // private claim  X-AUTHORITIES : [aaa] X-USERNAME : [bbb]

    } catch (UnsupportedEncodingException | JWTVerificationException e) {
        e.printStackTrace();
    }
}

Exceptions that can occur during verify

The exceptions that are not exhaustive but are likely to occur (prone to occur) are: Both exceptions are subclasses of JWTVerificationException.

exception description
SignatureVerificationException When the secret key is different, etc.
AlgorithmMismatchException When the signature algorithm is different, etc.
JWTDecodeException For example, if the token has been tampered with
TokenExpiredException If the token has expired
InvalidClaimException Before starting to use the token, etc.

Spring Security configuration

Shows the changed parts for JWT.

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserRepository userRepository;

    // ★1
    @Value("${security.secret-key:secret}")
    private String secretKey = "secret";

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            // AUTHORIZE
            .authorizeRequests()
                .mvcMatchers("/hello/**")
                    .permitAll()
                .mvcMatchers("/user/**")
                    .hasRole("USER")
                .mvcMatchers("/admin/**")
                    .hasRole("ADMIN")
                .anyRequest()
                    .authenticated()
            .and()
            // EXCEPTION
            .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint())
                .accessDeniedHandler(accessDeniedHandler())
            .and()
            // LOGIN
            .formLogin()
                .loginProcessingUrl("/login").permitAll()
                    .usernameParameter("email")
                    .passwordParameter("pass")
                .successHandler(authenticationSuccessHandler())
                .failureHandler(authenticationFailureHandler())
            .and()
            // ★2 LOGOUT
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(logoutSuccessHandler())
            .and()
            // ★3 CSRF
            .csrf()
                .disable()
            // ★4 AUTHORIZE
            .addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
            // ★5 SESSION
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            ;
        // @formatter:on
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.eraseCredentials(true)
                .userDetailsService(simpleUserDetailsService())
                .passwordEncoder(passwordEncoder());
    }

    @Bean("simpleUserDetailsService")
    UserDetailsService simpleUserDetailsService() {
        return new SimpleUserDetailsService(userRepository);
    }

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

    GenericFilterBean tokenFilter() {
        return new SimpleTokenFilter(userRepository, secretKey);
    }

    AuthenticationEntryPoint authenticationEntryPoint() {
        return new SimpleAuthenticationEntryPoint();
    }

    AccessDeniedHandler accessDeniedHandler() {
        return new SimpleAccessDeniedHandler();
    }

    AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new SimpleAuthenticationSuccessHandler(secretKey);
    }

    AuthenticationFailureHandler authenticationFailureHandler() {
        return new SimpleAuthenticationFailureHandler();
    }

    LogoutSuccessHandler logoutSuccessHandler() {
        return new HttpStatusReturningLogoutSuccessHandler();
    }

}

Authentication and processing on success / failure

There was no change in the settings around authentication.

configuration


.formLogin()
    .loginProcessingUrl("/login").permitAll()
        .usernameParameter("email")
        .passwordParameter("pass")
    .successHandler(authenticationSuccessHandler())
    .failureHandler(authenticationFailureHandler())

successHandler()

Before the change, it was a handler that only returned HTTP status 200, but it has been changed to generate a token from the authentication information and set it in the response header.

AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new SimpleAuthenticationSuccessHandler(secretKey);
}

Since the information set in the token payload (claim) can be decoded by anyone, select the information that can be exposed to the outside. In this example, the user ID is set in the subject, and at the time of authorization, the user ID is acquired from the token and the user is searched.

String token = JWT.create()
        .withIssuedAt(issuedAt)        //JWT issuance time
        .withNotBefore(notBefore)      //JWT expiration start time
        .withExpiresAt(expiresAt)      //JWT expiration date
        .withSubject(loginUser.getUser().getId().toString()) //Unique or globally unique value within the context of the JWT subject, JWT issuer
        .sign(this.algorithm);

The generateToken method generates a token from the credentials, and the setToken method sets the token in the Authorization header.

SimpleAuthenticationSuccessHandler


@Slf4j
public class SimpleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    final private Algorithm algorithm;

    public SimpleAuthenticationSuccessHandler(String secretKey) {
        Objects.requireNonNull(secretKey, "secret key must be not null");
        try {
            this.algorithm = Algorithm.HMAC256(secretKey);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication auth) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.info("Response has already been committed.");
            return;
        }
        setToken(response, generateToken(auth));
        response.setStatus(HttpStatus.OK.value());
        clearAuthenticationAttributes(request);
    }

    private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

    private String generateToken(Authentication auth) {
        SimpleLoginUser loginUser = (SimpleLoginUser) auth.getPrincipal();
        Date issuedAt = new Date();
        Date notBefore = new Date(issuedAt.getTime());
        Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);
        String token = JWT.create()
                .withIssuedAt(issuedAt)
                .withNotBefore(notBefore)
                .withExpiresAt(expiresAt)
                .withSubject(loginUser.getUser().getId().toString())
                .sign(this.algorithm);
        log.debug("generate token : {}", token);
        return token;
    }

    private void setToken(HttpServletResponse response, String token) {
        response.setHeader("Authorization", String.format("Bearer %s", token));
    }

    /**
     * Removes temporary authentication-related data which may have been stored in the
     * session during the authentication process.
     */
    private void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return;
        }
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }

}

Token verification and authorization

We have implemented a new filter that validates and authorizes tokens. Set this filter to be executed before the filter (UsernamePasswordAuthenticationFilter) that authenticates by user name and password.

configuration


.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)

SimpleTokenFilter

GenericFilterBean tokenFilter() {
    return new SimpleTokenFilter(userRepository, secretKey);
}

Get the token from the request header "Authorization" and validate it. In addition to checking whether the token has been tampered with, the verification also checks the set expiration date. If the token is normal, the user ID for searching the user information is obtained from the payload and the user is searched.

SimpleTokenFilter


@Slf4j
public class SimpleTokenFilter extends GenericFilterBean {

    final private UserRepository userRepository;
    final private Algorithm algorithm;

    public SimpleTokenFilter(UserRepository userRepository, String secretKey) {
        Objects.requireNonNull(secretKey, "secret key must be not null");
        this.userRepository = userRepository;
        try {
            this.algorithm = Algorithm.HMAC256(secretKey);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String token = resolveToken(request);
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            authentication(verifyToken(token));
        } catch (JWTVerificationException e) {
            log.error("verify token error", e);
            SecurityContextHolder.clearContext();
            ((HttpServletResponse) response).sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(ServletRequest request) {
        String token = ((HttpServletRequest) request).getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            return null;
        }
        return token.substring(7);
    }

    private DecodedJWT verifyToken(String token) {
        JWTVerifier verifier = JWT.require(algorithm).build();
        return verifier.verify(token);
    }

    private void authentication(DecodedJWT jwt) {
        Long userId = Long.valueOf(jwt.getSubject());
        userRepository.findById(userId).ifPresent(user -> {
            SimpleLoginUser simpleLoginUser = new SimpleLoginUser(user);
            SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(simpleLoginUser, null, simpleLoginUser.getAuthorities()));
        });
    }

}

Processing at logout

The only settings at logout are the URL and handler. I wanted to invalidate the token issued at the time of logout, but as far as I checked, it seems that the token cannot be invalidated, so the handler only returns the HTTP status.

configuration


.logout()
    .logoutUrl("/logout")
    //.invalidateHttpSession(true)
    //.deleteCookies("JSESSIONID")
    .logoutSuccessHandler(logoutSuccessHandler())

Token invalidation

When dealing with invalidation on the server side, there seems to be a method of holding the token of the logged out user for a certain period of time (until the token expiration date), and not authorizing if it matches during the authorization process. ..

CSRF

I don't use CSRF, so I disabled it.

configuration


.csrf()
    .disable()
    //.ignoringAntMatchers("/login")
    //.csrfTokenRepository(new CookieCsrfTokenRepository())

SessionManagement

Since session management is not performed, I set it to stateless. With this setting, Spring Security will not use HttpSession.

configuration


.sessionManagement()
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

API operation check

By changing to JWT, session cookies and CSRF tokens are no longer required. Instead, set the JWT token received after authentication in the request header.

Login API

** For a valid account **

If the authentication is successful, the token will be set in the Authorization header as shown in the example below.

> curl -i -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=iWKw06pvj"

HTTP/1.1 200
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmJmIjoxNTIzNTExNjY5LCJleHAiOjE1MjM1MTIyNjksImlhdCI6MTUyMzUxMTY2OX0.E6HZShowNPUvNj84dYRHMyZROxIvYjsEP7e29
_QLXic
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Thu, 12 Apr 2018 05:41:09 GMT

** For invalid accounts **

Wrong email address, wrong password, etc.

> curl -i -X POST "http://localhost:9000/app/login" -d "[email protected]" -d "pass=hogehoge"

HTTP/1.1 401

API that can be accessed by authenticated users

** If you specify a valid token **

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 200

** If no token is specified **

> curl -i "http://localhost:9000/app/memo/1"

HTTP/1.1 401

** If the token is tampered with **

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401

** If the token has expired **

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401

APIs that require authentication and USER roles

** For users with the USER role **

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/user"

HTTP/1.1 200

APIs that require authentication and the ADMIN role

** For users with the ADMIN role **

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 200

** For users who do not have the ADMIN role **

If the token is successful but the user does not have the required role, an error will occur.

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 403

Recommended Posts

Implement a simple Rest API with Spring Security & JWT with Spring Boot 2.0
Implement a simple Rest API with Spring Security with Spring Boot 2.0
Implement a simple Web REST API server with Spring Boot + MySQL
Create a simple demo site with Spring Security with Spring Boot 2.1
Implement REST API in Spring Boot
Implement REST API with Spring Boot and JPA (Infrastructure layer)
Let's make a simple API with EC2 + RDS + Spring boot ①
Implement REST API with Spring Boot and JPA (domain layer)
I made a simple search form with Spring Boot + GitHub Search API.
Create a simple search app with Spring Boot
Create a web api server with spring boot
Hello World (REST API) with Apache Camel + Spring Boot 2
[Spring Boot] Get user information with Rest API (beginner)
Customize REST API error response with Spring Boot (Part 2)
A memorandum when creating a REST service with Spring Boot
Customize REST API error response with Spring Boot (Part 1)
Spring with Kotorin --4 REST API design
[Beginner] Let's write REST API of Todo application with Spring Boot
Create a simple on-demand batch with Spring Batch
Implement CRUD with Spring Boot + Thymeleaf + MySQL
Implement paging function with Spring Boot + Thymeleaf
Achieve BASIC authentication with Spring Boot + Spring Security
Create a website with Spring Boot + Gradle (jdk1.8.x)
Hash passwords with Spring Boot + Spring Security (with salt, with stretching)
Try LDAP authentication with Spring Security (Spring Boot) + OpenLDAP
Try to implement login function with Spring Boot
[Introduction to Spring Boot] Authentication function with Spring Security
Create a Spring Boot development environment with docker
Create Spring Cloud Config Server with security with Spring Boot 2.0
Download with Spring Boot
Automatically map DTOs to entities with Spring Boot API
Spring Security usage memo: Cooperation with Spring MVC and Boot
[JUnit 5 compatible] Write a test using JUnit 5 with Spring boot 2.2, 2.3
Introduce swagger-ui to REST API implemented in Spring Boot
Generate barcode with Spring Boot
Hello World with Spring Boot
Handle Java 8 date and time API with Thymeleaf with Spring Boot
Get started with Spring boot
Hello World with Spring Boot!
A story packed with the basics of Spring Boot (solved)
Run LIFF with Spring Boot
SNS login with Spring Boot
Login function with Spring Security
Spring Boot starting with Docker
Hello World with Spring Boot
Set cookies with Spring Boot
REST API testing with REST Assured
Use Spring JDBC with Spring Boot
With Spring boot, password is hashed and member registration & Spring security is used to implement login function.
Add module with Spring Boot
Getting Started with Spring Boot
Link API with Spring + Vue.js
Try using Spring Boot Security
Create microservices with Spring Boot
Send email with spring boot
Let's find out how to receive in Request Body with REST API of Spring Boot
I made a simple MVC sample system using Spring Boot
Let's make a book management web application with Spring Boot part1
I get a 404 error when testing forms authentication with Spring Security
I implemented an OAuth client with Spring Boot / Security (LINE login)
Let's make a book management web application with Spring Boot part3