xand.es

Spring Security configuration for REST applications (I)

Summary

The main purpose of this implementation is to secure an application using Spring Boot. We assume that the application uses JWT for the purposes of user's authentication. Please, keep in mind that this is a very basic authentication/authorization mechanism designed for demonstration purposes only. In production, you should use more sofisticated mechanisms, like persistent repositories, etc.

Basically, for each request there will be an instance of RequestContext class accessible by RequestContextHolder and associated to a thread processing the request. This object will contain all the information about the user and the JWT generated. Since it does not keep any dependency with Spring Security framework you may access this information at any point inside your code.

When our application receives a request, JWTTokenFilter comes into play. This class extends Spring Security's OncePerRequestFilter, so it filters all the incoming request. The main role of this filter is to verify JWT provided by the user, e.g. valid signature, not expired, etc. Also, it keeps track of revoked tokens (after user logout) using JWTRepository.

AccessDeniedHandlerJWT is a handler which processes the 403 HTTP Status code returning an instance of Error403 class when the user performing the request lacks privileges for accesing resource. Basically this handler comes into play when user does not pass @PreAuthorize constraint.

AuthenticationEntryPointJWT is a class which implements AuthenticationEntryPoint interface from Spring Security framework. The logic contained in this handler is fired when user performs a bad login, e.g. invalid username/password pair and there is a need to return 401 HTTP Status code. An object of class Error401 will contain the information returned to user.

Finally a controller AuthApiImpl will perform a user login checking username/password pair. This controller is also in charge of user's logout process update revoked tokens database.

On the following diagram you may see an overview of components involved in the implementation:

AppRunner

This is the main class for application startup. The only special thing about this class are the annotations. Especially note the contents of @ComponentScan annotation since this are the packages where our Spring Boot application are going to find component definitions.


package com.xand.authorizer;

import org.openapitools.jackson.nullable.JsonNullableModule;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.ExitCodeGenerator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@SpringBootApplication
@ComponentScan(basePackages = {
        "com.xand.authorizer.oas.api",
        "com.xand.authorizer.config",
        "com.xand.authorizer.advice",
        "com.xand.authorizer.filter"
})
public class AppRunner implements CommandLineRunner {
    @Override
    public void run(String... arg0) throws Exception {
        if (arg0.length > 0 && arg0[0].equals("exitcode")) {
            throw new ExitException();

        }
    }

    public static void main(String[] args) throws Exception {
        new SpringApplication(AppRunner.class).run(args);
    }

    static class ExitException extends RuntimeException implements ExitCodeGenerator {
        private static final long serialVersionUID = 1L;

        @Override
        public int getExitCode() {
            return 10;
        }

    }

    @Bean
    public WebMvcConfigurer webConfigurer() {
        return new WebMvcConfigurer() {
        };
    }

    @Bean
    public com.fasterxml.jackson.databind.Module jsonNullableModule() {
        return new JsonNullableModule();
    }

}

SecurityConfig

Once AppRunner is defined we need to perform Spring Security configuration. SecurityConfig is the class responsible for the whole configuration of our security framework.

Note the following:

  • @EnableGlobalMethodSecurty annotation with prePostEnabled=true. This allows us to use @PreAuthorize annotations on controller's methods for fine-grained access based on roles or any valid SpEL expression.

  • The class extends WebSecurityConfigurerAdapter.

  • We allow unrestricted access for anything under /api/v1/public (GET, POST) path and we require user to be authenticated for anything else. Also we allow all Swagger stuff to be accesible without authentication, e.g. /swagger-ui/**, /swagger-resources/**, /api-docs.

Alongside the global security configuration this class instantiates the following components:

  • UserDetailsService. This is the repository for user's information. For development purposes we will use a simple InMemoryUserDetailsManager. In order to use it you should define in your application-dev.properties something like:

# ------------------------------------------------------
# Security configuration (dev)
# This configuration only applies in development
# ------------------------------------------------------
app.security.user-details.users=admin;user;nobody
app.security.user-details.passwords=admin;user;nobody
app.security.user-details.roles=ADMIN;USER;NOBODY

Obviously you will need to use a real repository in production.

  • PasswordEncoder. BCrypt password encoder.

  • JWTTokenRepository. An object of this class keeps track of all issued/revoked JWT. This class is a Runnable and it executes in a separate Thread in order to perform cleanup of expired tokens.


package com.xand.authorizer.config;

import com.xand.authorizer.advice.AuthenticationEntryPointJWT;
import com.xand.authorizer.filter.JWTTokenFilter;
import com.xand.authorizer.util.Constants;
import com.xand.authorizer.util.JWTRepository;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

import java.util.Arrays;

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private Logger log = LoggerFactory.getLogger(SecurityConfig.class);

    @Value("${app.security.user-details.users}")
    private String devUsersProperties;

    @Value("${app.security.user-details.passwords}")
    private String devPasswordsProperties;

    @Value("${app.security.user-details.roles}")
    private String devRolesProperties;

    private final JWTTokenFilter jwtTokenFilter;

    private final AuthenticationEntryPointJWT authenticationEntrypointJWT;

    private final Environment environment;

    public SecurityConfig(@Autowired final JWTTokenFilter jwtTokenFilter,
                          @Autowired final AuthenticationEntryPointJWT authenticationEntrypointJWT,
                          @Autowired final Environment environment) {
        this.jwtTokenFilter = jwtTokenFilter;
        this.authenticationEntrypointJWT = authenticationEntrypointJWT;
        this.environment = environment;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // Enable CORS and disable CSRF
        http = http.cors().and().csrf().disable();

        http = http.httpBasic().disable().formLogin().disable();

        // set 401/403 handlers
        http = http
                .exceptionHandling().authenticationEntryPoint(this.authenticationEntrypointJWT)
                .and();

        // Set session management to stateless
        http = http
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and();

        // Set permissions on endpoints
        http.authorizeRequests()
            // Our public endpoints
            .antMatchers(HttpMethod.GET,
                    "/swagger-ui/**",
                    "/swagger-resources/**",
                    "/api-docs", "/api/v1/public/**" ).permitAll()
            .antMatchers(HttpMethod.POST,
        "/api/v1/public/**", "/api/v1/auth" ).permitAll()
            // Our private endpoints
            .anyRequest().authenticated();

        // Add JWT token filter
        http.addFilterBefore(
            jwtTokenFilter,
            UsernamePasswordAuthenticationFilter.class
        );
    }

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

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        UserDetailsService output = null;

        if(this.isDevEnvironment()) {
            InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();

            // build development UserDetailsService
            String[] users = StringUtils.split(this.devUsersProperties, ";");
            String[] passwords = StringUtils.split(this.devPasswordsProperties, ";");
            String[] roles = StringUtils.split(this.devRolesProperties, ";");

            for(int i = 0; i < users.length; i++) {
                String user = users[i];
                String password = passwords[i];
                String role = roles[i];

                UserDetails userDetails = User.withUsername(user)
                        .passwordEncoder(passwordEncoder()::encode)
                        .password(password)
                        .roles(role)
                        .build();

                userDetailsManager.createUser(userDetails);
            }

            output = userDetailsManager;
        } else {
            // build a real UserDetailsService
        }

        return output;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public JWTRepository jwtRevokeRepository() {
        JWTRepository output = new JWTRepository();

        return output;
    }

    private boolean isDevEnvironment() {
        String[] activeProfiles = this.environment.getActiveProfiles();
        return Arrays.asList(activeProfiles).contains(Constants.ENVIRONMENT_DEV);
    }

}

AccessDeniedHandlerJWT

This component handles 403 (Forbidden) HTTP status. When Spring Security detects that user tries to access a resource which is not allowed to, for example via @PreAuthorize annotation it will throw AccessDeniedException. Since this class is annotated with @ControllerAdvice it will intercept the exception thanks to @ExceptionHandler annotation and send the appropriate response.

Also, when you want to deny access to some resource inside your code you may throw AccessDeniedException in order to prevent the access and automatically return 403 HTTP status code.


package com.xand.authorizer.advice;

import com.xand.authorizer.exception.AppAccessDeniedException;
import com.xand.authorizer.oas.model.Error403;
import com.xand.authorizer.util.RequestContext;
import com.xand.authorizer.util.RequestContextHolder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import javax.servlet.http.HttpServletRequest;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * This class treats 403 error. This means that we know who the user is, but the access is
 * explicitly forbidden
 */
@ControllerAdvice
public class AccessDeniedHandlerJWT {

    private static final SimpleDateFormat TSTAMP_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity handle(AccessDeniedException ex, HttpServletRequest request) {
        RequestContext requestContext = RequestContextHolder.get();
        String user = requestContext.getJwt().getSubject();

        Error403 err = this.buildError403(request, user, ex.getMessage());

        ResponseEntity output = new ResponseEntity<>(err, HttpStatus.FORBIDDEN);
        return output;
    }

    @ExceptionHandler(AppAccessDeniedException.class)
    public ResponseEntity handle(AppAccessDeniedException ex, HttpServletRequest request) {
        Error403 err = this.buildError403(request, null, ex.getMessage());

        ResponseEntity output = new ResponseEntity<>(err, HttpStatus.FORBIDDEN);
        return output;
    }

    private Error403 buildError403(HttpServletRequest request, String user, String exceptionMessage) {
        String queryString = request.getQueryString();
        if(queryString == null) {
            queryString = "";
        }
        String method = request.getMethod();
        String requestUri = request.getRequestURI();

        Error403 err = new Error403();
        err.setTimestamp(TSTAMP_FORMAT.format(new Date()));

        if(user != null) {
            err.setError("Access is denied for user " + user);
        } else {
            err.setError(exceptionMessage);
        }

        err.setQueryString(queryString);
        err.setMethod(method);
        err.setRequestUri(requestUri);

        return err;
    }

}

AuthenticationEntryPointJWT

This class acts as an interceptor for failed login attempts. Basically when no JWT token is provided in the Authorization header and, as a consequence, Spring Security is unable to build SecurityContextHandler it will return 401 HTTP status. After that, this class will intercept the request in order to build appropriate response.


package com.xand.authorizer.advice;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.xand.authorizer.oas.model.Error401;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * This class treats 401 error when no token is provided.
 */
@Component
public class AuthenticationEntryPointJWT implements AuthenticationEntryPoint {

    private static final SimpleDateFormat TSTAMP_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        String queryString = request.getQueryString();
        if(queryString == null) {
            queryString = "";
        }
        String method = request.getMethod();
        String requestUri = request.getRequestURI();

        Error401 err = new Error401();
        err.setTimestamp(TSTAMP_FORMAT.format(new Date()));
        err.setError("You need to perform a full authentication before accesing this resource");
        err.setQueryString(queryString);
        err.setMethod(method);
        err.setRequestUri(requestUri);

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        this.objectMapper.writeValue(response.getOutputStream(), err);
    }
}

JWTTokenFilter

This class is a filter so it extends OncePerRequestFilter. This filter is responsible for building SecurityContextHolder. It intercepts all HTTP requests, looks for JWT within the Authorization header, parses it and performs the checks, e.g. signature, expiration, etc.

In case no token is provided inside the Authorization header or the token provided is invalid (expired, not valid signature, etc) the flow continues as normal until it hits Spring Security internals which makes the decision to allow the request (endpoints avaialble publicly) or to return 403 HTTP Status.


package com.xand.authorizer.filter;

import com.xand.authorizer.util.JWT;
import com.xand.authorizer.util.JWTRepository;
import com.xand.authorizer.util.RequestContext;
import com.xand.authorizer.util.RequestContextHolder;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.crypto.SecretKey;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.List;

@Component
public class JWTTokenFilter extends OncePerRequestFilter {

    @Value("${app.security.jws.secretKey}")
    private String jwsSecret;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JWTRepository jwtRepository;

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

        // Get authorization header and validate
        final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.isEmpty(header) || !header.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Get jwt token and validate
        String token = header.substring("Bearer ".length());
        token = token.trim();

        SecretKey secretKey = Keys.hmacShaKeyFor(this.jwsSecret.getBytes());

        String username;
        String tokenId;
        OffsetDateTime expiration;
        OffsetDateTime issuedAt;
        String issuer;
        try {
            Jws claimsJws = Jwts.parserBuilder().setSigningKey(secretKey).build()
                    .parseClaimsJws(token);

            // at this point we also check that the token is not expired
            username = claimsJws.getBody().getSubject();
            tokenId = claimsJws.getBody().getId();
            issuer = claimsJws.getBody().getIssuer();

            Date exp = claimsJws.getBody().getExpiration();
            expiration = exp.toInstant().atOffset(ZoneOffset.UTC);

            Date dIssuedAt = claimsJws.getBody().getIssuedAt();
            issuedAt = dIssuedAt.toInstant().atOffset(ZoneOffset.UTC);
        } catch(JwtException e) {
            filterChain.doFilter(request, response);
            return;
        }

        // Check that token is not revoked
        boolean revoked = this.jwtRepository.isTokenRevoked(tokenId);
        if(revoked) {
            filterChain.doFilter(request, response);
            return;
        }

        // Get user identity and set it on the spring security context
        UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

        UsernamePasswordAuthenticationToken
            authentication = new UsernamePasswordAuthenticationToken(
                userDetails, null,
                userDetails == null ?
                    List.of() : userDetails.getAuthorities()
            );

        authentication.setDetails(
            new WebAuthenticationDetailsSource().buildDetails(request)
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);

        // create JWT object
        JWT jwt = new JWT();
        jwt.setSubject(username);
        jwt.setExpiration(expiration);
        jwt.setId(tokenId);
        jwt.setRawToken(token);
        jwt.setIssuer(issuer);
        jwt.setIssuedAt(issuedAt);

        String remoteIp = request.getRemoteAddr();
        String userAgent = request.getHeader("User-Agent");

        // create and populate RequestContext object
        RequestContext requestContext = new RequestContext();
        requestContext.setJwt(jwt);
        requestContext.setRemoteIp(remoteIp);
        requestContext.setRemoteUserAgent(userAgent);

        userDetails.getAuthorities().forEach(it -> {
            requestContext.getAuthorities().add(it.getAuthority());
        });

        RequestContextHolder.set(requestContext);

        filterChain.doFilter(request, response);
    }
}

AuthApiImpl

This class is responsible for user authentication and logout.

When authentication is performed this class simply verifies username/password and generates JWT. If username/password pair is invalid and UnathorizedException is thrown.

On the other side, when logout is performed the class simply informs JWTRepository that the JWT is revoked.


package com.xand.authorizer.oas.api;

import com.xand.authorizer.exception.UnathorizedException;
import com.xand.authorizer.oas.model.AuthUserRequest;
import com.xand.authorizer.oas.model.AuthUserResponse;
import com.xand.authorizer.oas.model.Error401;
import com.xand.authorizer.oas.model.LogoutResponse;
import com.xand.authorizer.util.*;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.PostConstruct;
import javax.crypto.SecretKey;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.text.SimpleDateFormat;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;

@RestController
public class AuthApiImpl implements AuthApi {

    private static final SimpleDateFormat TSTAMP_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    private Logger log = LoggerFactory.getLogger(AuthApiImpl.class);

    private PasswordEncoder passwordEncoder;

    private UserDetailsService userDetailsService;

    private JWTRepository jwtRepository;

    @Value("${app.security.jws.secretKey}")
    private String jwsSecret;

    @Value("${app.security.jws.issuer}")
    private String jwsIssuer;

    @Value("${app.security.jws.validSeconds}")
    private String sJwsValidSeconds;
    private int jwsValidSeconds;

    public AuthApiImpl(UserDetailsService userDetailsService,
                       PasswordEncoder passwordEncoder,
                       JWTRepository jwtRepository) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
        this.jwtRepository = jwtRepository;
    }

    @PostConstruct
    public void init() {
        this.jwsValidSeconds = Integer.parseInt(sJwsValidSeconds);
    }

    @Override
    public ResponseEntity authUser(@Valid AuthUserRequest authUserRequest) {
        RequestContext requestContext = RequestContextHolder.get();

        AuthUserResponse res;
        String jws;
        if(requestContext == null) {
            // normal user login
            String username = authUserRequest.getUsername().trim();
            String password = authUserRequest.getPassword().trim();

            UserDetails userDetails;
            try {
                userDetails = this.userDetailsService.loadUserByUsername(username);
            } catch(Throwable t) {
                // exit immediately
                throw new UnathorizedException(username, Constants.MSG_LOGIN_FAILED, false);
            }

            String encodedPassword = userDetails.getPassword();
            boolean passwordMatch = false;

            try {
                passwordMatch = this.passwordEncoder.matches(password, encodedPassword);
            } catch(Throwable t) {
                log.error("An error occured", t);
            }

            if(!passwordMatch) {
                // exit immediately
                // additionally you shoud report this attempt to the audit,
                // since it may be a brute force attack the user provided
                throw new UnathorizedException(username, Constants.MSG_LOGIN_FAILED, true);
            }

            List sAuthorities = new ArrayList<>();
            Collection authorities = userDetails.getAuthorities();
            for(GrantedAuthority ga : authorities) {
                String authority = ga.getAuthority();
                sAuthorities.add(authority);
            }

            // TODO: Do something with variables below
            boolean accountNonExpired = userDetails.isAccountNonExpired();
            boolean accountNonLocked = userDetails.isAccountNonLocked();
            boolean credentialsNonExpired = userDetails.isCredentialsNonExpired();
            boolean enabled = userDetails.isEnabled();

            OffsetDateTime issuedAt = OffsetDateTime.now(ZoneOffset.UTC);
            OffsetDateTime expiration = issuedAt.plusSeconds(this.jwsValidSeconds);

            SecretKey secretKey = Keys.hmacShaKeyFor(this.jwsSecret.getBytes());

            String tokenId = UUID.randomUUID().toString();

            String rawToken = Jwts.builder()
                    .setIssuer(this.jwsIssuer)
                    .setSubject(username)
                    .setId(tokenId)
                    .claim("authorities", sAuthorities)
                    .setIssuedAt(Date.from(issuedAt.toInstant()))
                    .setExpiration(Date.from(expiration.toInstant()))
                    .signWith(secretKey)
                    .compact();

            jws = "Bearer " + rawToken;

            res = new AuthUserResponse();
            res.setAccessToken(jws);
            res.setIssuer(this.jwsIssuer);
            res.setSubject(username);
            res.setAuthorities(sAuthorities);
            res.setIssuedAt(issuedAt);
            res.setExpiration(expiration);

            // save generated token into repository
            JWT jwt = new JWT();
            jwt.setId(tokenId);
            jwt.setExpiration(expiration);
            jwt.setSubject(username);
            jwt.setRawToken(rawToken);

            this.jwtRepository.put(jwt);
        } else {
            // the user is already authenticated
            // return the original token
            jws = "Bearer " + requestContext.getJwt().getRawToken();

            List authorities = new ArrayList<>();
            requestContext.getAuthorities().forEach(it -> {
                authorities.add(it);
            });

            res = new AuthUserResponse();
            res.setAccessToken(jws);
            res.setIssuer(requestContext.getJwt().getIssuer());
            res.setSubject(requestContext.getJwt().getSubject());
            res.setAuthorities(authorities);
            res.setIssuedAt(requestContext.getJwt().getIssuedAt());
            res.setExpiration(requestContext.getJwt().getExpiration());
        }

        return ResponseEntity.ok().header(HttpHeaders.AUTHORIZATION, jws).body(res);
    }

    @Override
    public ResponseEntity revokeJwt() {
        RequestContext requestContext = RequestContextHolder.get();
        JWT jwt = requestContext.getJwt();

        this.jwtRepository.revoke(jwt);

        LogoutResponse res = new LogoutResponse();
        res.setUsername(jwt.getSubject());

        return new ResponseEntity<>(res, HttpStatus.OK);
    }

    @ExceptionHandler(value = UnathorizedException.class)
    public ResponseEntity handle401(UnathorizedException ex,
                                              HttpServletRequest request) {

        String queryString = request.getQueryString();
        if(queryString == null) {
            queryString = "";
        }
        String method = request.getMethod();
        String requestUri = request.getRequestURI();

        Error401 err = new Error401();
        err.setTimestamp(TSTAMP_FORMAT.format(new Date()));
        err.setError(ex.getMessage());
        err.setQueryString(queryString);
        err.setMethod(method);
        err.setRequestUri(requestUri);

        ResponseEntity output = new ResponseEntity<>(err, HttpStatus.UNAUTHORIZED);
        return output;
    }
}

JWTRepository

An object of this class is used to keep track of all issued JWT. Also, when user performs a logout we revoke the token used by the user and we keep it inside a collection of revoked tokens.

This class is a Runnable. This way we may perform a periodic cleanup of expired tokens.


package com.xand.authorizer.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.*;

/**
 * This class keeps track of issued and revoked token. It is a Runnable, so it executes in a separate thread.
 * This way we may perioridically check and remove expired tokens.
 */
public class JWTRepository implements Runnable {

    private static final Logger log = LoggerFactory.getLogger(JWTRepository.class);

    private final Map issued = new HashMap<>();
    private final Map revoked = new HashMap<>();

    private Thread thread;
    private boolean running = true;

    public JWTRepository() {
        this.thread = new Thread(this);
        this.thread.start();
    }

    public void put(JWT jwt) {
        synchronized (this.issued) {
            this.issued.put(jwt.getId(), jwt);
        }
    }

    public JWT get(String id) {
        return this.issued.get(id);
    }

    public boolean isTokenRevoked(String id) {
        return this.revoked.containsKey(id);
    }

    public void revoke(JWT jwt) {
        synchronized (this.revoked) {
            this.revoked.put(jwt.getId(), jwt);
        }
    }

    public void run() {
        while(this.running) {
            List issuedToRemove = new ArrayList<>();
            List revokedToRemove = new ArrayList<>();

            synchronized (this.issued) {
                Collection issuedCol = this.issued.values();
                for(JWT jwt : issuedCol) {
                    OffsetDateTime expiration = jwt.getExpiration();
                    if(expiration.toInstant().isAfter(Instant.now())) {
                        issuedToRemove.add(jwt.getId());
                    }
                }

                for(String id : issuedToRemove) {
                    this.issued.remove(id);
                }
            }

            synchronized (this.revoked) {
                Collection revokedCol = this.revoked.values();
                for(JWT jwt : revokedCol) {
                    OffsetDateTime expiration = jwt.getExpiration();
                    if(expiration.toInstant().isAfter(Instant.now())) {
                        revokedToRemove.add(jwt.getId());
                    }
                }

                for(String id : revokedToRemove) {
                    this.revoked.remove(id);
                }
            }

            try {
                Thread.sleep(5000);
            } catch(InterruptedException e){
                log.error("An error occured", e);
            }
        }
    }

}

KeyApiImpl

This is just an example class which demonstrates the use of @PreAuthorize annotation. In this case, only users with ROLE_ADMIN are allowed to access the resource, other roles will get 403 Forbidden HTTP status code.

This class also demonstrates the use of RequestContext which is used to get information about current user, such as authorities and username (subject in terms of JWT).


package com.xand.authorizer.oas.api;

import com.xand.authorizer.oas.model.KeySlotRead;
import com.xand.authorizer.oas.model.KeySlotWrite;
import com.xand.authorizer.util.RequestContext;
import com.xand.authorizer.util.RequestContextHolder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;

@RestController
public class KeyApiImpl implements KeyApi {
    private static final Logger log = LoggerFactory.getLogger(KeyApiImpl.class);

    @Override
    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    public ResponseEntity postKey(@Valid KeySlotWrite keySlotWrite) {
        RequestContext requestContext = RequestContextHolder.get();
        log.debug("USER => {}", requestContext.getJwt().getSubject());

        KeySlotRead res = new KeySlotRead();

        ResponseEntity output = new ResponseEntity<>(res, HttpStatus.CREATED);
        return output;
    }

}

UnathorizedException

This exception is thrown when a failed attempt occurs.

This object holds the following information:

  • username. Username provided during authentication process.

  • message. Message shown to the user performing the request.

  • userExists. This boolean attribute indicates that the user actually exists in the user repository. This attribute may be used in order to detect that user's account is under brute force attack. Additionally you should be sure to not provide this attribute to the outside, since it may leave to a security breach. Basically, if you provide this attribute to an attacker you are disclosing that the account actually exists in our system.


package com.xand.authorizer.exception;

/**
 * Exception holding information about failed login.
 *
 * IMPORTANT: You MUST never show the value of userExists property to the client since it may
 * leave to a security breach.
 */
public class UnathorizedException extends RuntimeException {

    private final String username;

    private final boolean userExists;

    public UnathorizedException(final String username,
                                final String message,
                                final boolean userExists) {
        super(message);
        this.username = username;
        this.userExists = userExists;
    }

    public String getUsername() {
        return this.username;
    }

    public boolean isUserExists() {
        return this.userExists;
    }

}

AppAccessDeniedException

This is a RuntimeException which is thrown by the business layer in order to forbid the access to a resource. We use a separate exception for this task to not keep dependecy with Spring Security framework.


package com.xand.authorizer.exception;

public class AppAccessDeniedException extends RuntimeException {

    public AppAccessDeniedException(final String message) {
        super(message);
    }

}

RequestContext and RequestContextHolder

RequestContextHolder is just a class responsible for bounding RequestContext to a current Thread using ThreadLocal. In order to get the RequestContext object we may issue a call to RequestContextHolder.get() at any point in our code.

RequestContext object holds all the security related information associated to a request. We use a custom class in order to avoid the dependency with Spring framework, so we can use this class anywhere inside the code.


package com.xand.authorizer.util;

public class RequestContextHolder {

    private static final ThreadLocal context = new ThreadLocal<>();

    public static void set(RequestContext requestContext) {
        context.set(requestContext);
    }

    public static RequestContext get() {
        return context.get();
    }

    public static void clean() {
        context.remove();
    }

}

package com.xand.authorizer.util;

import lombok.Getter;
import lombok.Setter;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

@Getter
@Setter
public class RequestContext implements Serializable {

    private JWT jwt;

    private String remoteIp;

    private String remoteUserAgent;

    private List authorities = new ArrayList<>();
}

Error401 and Error403

These classes are simple models which contain information about the error. They are generated using Swagger and they are part of the API Rest specification.

pom.xml

This pom.xml lists all necessary dependencies.


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.xand.authorizer</groupId>
    <artifactId>ov-authorizer-backend</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.6</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>11</java.version>
        <springboot-version>2.5.6</springboot-version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>${springboot-version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
            <version>${springboot-version}</version>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.2</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.2</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
            <version>0.11.2</version>
            <scope>runtime</scope>
        </dependency>

    </dependencies>


    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.0</version>
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>2.2.1.RELEASE</version>
            </plugin>
        </plugins>
    </build>
</project>