How to Secure REST Endpoint Using Spring Boot and OAuth2
Overview
In this article, I will provide a simple example to secure REST example by using Oauth2. I will not explain what Oauth2 protocol is all about in detail. In short, to implement this authorization framework, we need:
- Authorization Server
- Resource Server
- Client
- Resource Owner
I found this link is one of the best explanation regarding OAuth2 framework.
Use Case
I will create a simple OAuth2 authorization framework using spring-boot 2.1.x. The authorization server will have two scopes, which are READ and WRITE. It has 4 grant types, but for the rest I just use two types, which are PASSWORD and REFRESH TOKEN. And for the token itself, I will use JWT token.
All the source code are available in my github repository.
Step 1: Authorization Server
AuthorizationServer.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
@Configuration @EnableAuthorizationServer public class AuthorizationServer extends AuthorizationServerConfigurerAdapter implements ApplicationContextAware { private static final String JWT_SIGNING_KEY = "JWT_SIGNING_KEY"; private static final String CLIENT = "ru-rocker"; // HTTP Basic Auth username private static final String IMPLICIT = "implicit"; private static final String REFRESH_TOKEN = "refresh_token"; private static final String AUTHORIZATION_CODE = "authorization_code"; private static final String PASSWORD = "password"; private static final String SECRET = "secret"; // HTTP Basic Auth password private static final String REALM = "DEMO_REALM"; private static final String[] SCOPES = { "read", "write" }; // Access token is only valid for 30 minutes. private static final int ACCESS_TOKEN_VALIDITY_SECONDS = 1800; // Refresh token is only valid for 60 minutes. private static final int REFRESH_TOKEN_VALIDITY_SECONDS = 3600; ApplicationContext applicationContext; @Autowired @Qualifier("authenticationManagerBean") private AuthenticationManager authenticationManager; @Override public void configure(final ClientDetailsServiceConfigurer clients) throws Exception { // @formatter:off clients.inMemory() .withClient(CLIENT) .authorizedGrantTypes(PASSWORD, AUTHORIZATION_CODE, REFRESH_TOKEN, IMPLICIT) .scopes(SCOPES) .secret(this.passwordEncoder().encode(SECRET)) .accessTokenValiditySeconds(ACCESS_TOKEN_VALIDITY_SECONDS) .refreshTokenValiditySeconds(REFRESH_TOKEN_VALIDITY_SECONDS); // @formatter:on } /** * Apply the token converter (and enhancer) for token store. * * @return the JwtTokenStore managing the tokens. */ @Bean public JwtTokenStore tokenStore() { return new JwtTokenStore(this.jwtAccessTokenConverter()); } /** * This bean generates an token enhancer, which manages the exchange between JWT access tokens and Authentication * in both directions. * * @return an access token converter configured with the authorization server's public/private keys */ @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { final JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setSigningKey(JWT_SIGNING_KEY); return converter; } @Bean public PasswordEncoder passwordEncoder() { // on purpose using NoOpPasswordEncoder for demo // can use BCrypt or other encoder return NoOpPasswordEncoder.getInstance(); } @Override public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception { final Collection<TokenEnhancer> tokenEnhancers = applicationContext.getBeansOfType(TokenEnhancer.class) .values(); final TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain(); tokenEnhancerChain.setTokenEnhancers(new ArrayList<>(tokenEnhancers)); // @formatter:off endpoints.tokenStore(this.tokenStore()) .accessTokenConverter(this.jwtAccessTokenConverter()) .authenticationManager(authenticationManager) .tokenEnhancer(tokenEnhancerChain); // @formatter:on } @Override public void configure(final AuthorizationServerSecurityConfigurer oauthServer) throws Exception { oauthServer.realm(REALM + "/client"); } @Override public void setApplicationContext(final ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } } |
OAuth2SecurityInMemoryConfiguration.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
@Configuration @EnableWebSecurity public class OAuth2SecurityInMemoryConfiguration extends WebSecurityConfigurerAdapter{ @Autowired public void globalUserDetails(final AuthenticationManagerBuilder auth) throws Exception { // @formatter:off auth.inMemoryAuthentication() .withUser("admin") .password("admin") .roles("ADMIN") .and() .withUser("user") .password("user") .roles("USER"); // @formatter:on } @Override protected void configure(final HttpSecurity http) throws Exception { http.csrf().disable().anonymous().disable().authorizeRequests().antMatchers("/oauth/token").permitAll(); } @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } } |
IssueAtTokenEnhancer.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@Component public class IssueAtTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(final OAuth2AccessToken accessToken, final OAuth2Authentication authentication) { this.addClaims((DefaultOAuth2AccessToken) accessToken); return accessToken; } private void addClaims(final DefaultOAuth2AccessToken accessToken) { final DefaultOAuth2AccessToken token = accessToken; Map<String, Object> additionalInformation = token.getAdditionalInformation(); if (additionalInformation.isEmpty()) { additionalInformation = new LinkedHashMap<>(); } // add "iat" claim with current time in secs // this is used for an inactive session timeout additionalInformation.put("iat", new Integer((int) (System.currentTimeMillis() / 1000L))); token.setAdditionalInformation(additionalInformation); } } |
Step 2: Resource Server
In this resoruce server, I created two parts. One part is for resource server configuration, another part is for creating REST endpoint.
application.yml
1 2 3 4 5 6 7 8 |
security: oauth2: resource: jwt: key-value: JWT_SIGNING_KEY server: port: 8181 |
ResourceServerConfig.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
@Configuration @EnableResourceServer @EnableGlobalMethodSecurity(prePostEnabled = true) public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(final HttpSecurity http) throws Exception { // @formatter:off http .exceptionHandling() .accessDeniedHandler(new OAuth2AccessDeniedHandler()) .authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED)) .and() .csrf() .disable() .headers() .frameOptions() .disable() .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/insecure/**").anonymous() .antMatchers("/api/secure/**").authenticated(); // @formatter:on } } |
HelloController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@RestController @RequestMapping("/api") public class HelloController { // anyone can access this endpoint @GetMapping(path = "/insecure/hello") public String getInsecureHello() { return "Hello Insecure"; } // only authenticated users with role USER // and scope read can access this @GetMapping(path = "/secure/hello") @PreAuthorize("hasRole('USER') and #oauth2.hasScope('read')") public String getSecureHello() { return "Hello Secure"; } // only authenticated users with role USER // and scope trust can access this. // Currently no scope trust, so this endpoint will be unavailable for any user. @GetMapping(path = "/secure/trust/hello") @PreAuthorize("hasRole('USER') and #oauth2.hasScope('trust')") public String getTrustHello() { return "Hello Trust"; } } |
Run It
Retrieve Access Token
Make a POST request into url /oauth/token with Basic Authorization. Fill username with your CLIENT name and password with your SECRET value. Those values are configured in AuthorizationServer.java
. The content-type must be application/x-www-form-urlencoded.
Request to Resource Server
All the requests to resource server require parameter access_token as part of the request.
Refresh Token
Just like retrieving access token, basic authorization must be set first with client and secret values. Then making a POST request with grant type refresh_token.
That’s All
Short posting for this one. Hope you enjoy it.
Hi
Thanks for the explanation.
I am wondering if this is based on new Spring Security 5 / Spring Boot 2.2 or older implementation using Spring Boot 1.5.x
as security seems to have changed quite a bit in the new spring security
I use spring security 5 and boot 2.x
I was wondering – do people typically secure the auth-server using SSL/TLS? I am trying to secure the auth-server from your example using a self-signed cert, but I can’t seem to get a token. I’m using Postman to call it, but it looks like it doesn’t get a response. Do you have an example that uses SSL/TLS?
Yes, you should use SSL/TLS for auth-server. Usually, my approach is to put Nginx as a proxy server and configure the TLS there. Unfortunately, I do not have any example for TLS./SSL.
Anyway, maybe you can use curl with –insecure flag first to bypass the request if you are using untrusted certificate.
Hope this helps.