From f755580a912efb5a1c72329267ec35b5391af1a5 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 5 Dec 2018 10:49:19 -0700 Subject: [PATCH] Resource Server Static Key Sample Fixes: gh-5486 --- .../oauth2resourceserver-static/README.adoc | 82 ++++++++++++++ ...es-boot-oauth2resourceserver-static.gradle | 12 +++ ...OAuth2ResourceServerApplicationITests.java | 101 ++++++++++++++++++ .../OAuth2ResourceServerApplication.java | 30 ++++++ .../OAuth2ResourceServerController.java | 38 +++++++ ...h2ResourceServerSecurityConfiguration.java | 65 +++++++++++ 6 files changed, 328 insertions(+) create mode 100644 samples/boot/oauth2resourceserver-static/README.adoc create mode 100644 samples/boot/oauth2resourceserver-static/spring-security-samples-boot-oauth2resourceserver-static.gradle create mode 100644 samples/boot/oauth2resourceserver-static/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java create mode 100644 samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerApplication.java create mode 100644 samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerController.java create mode 100644 samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java diff --git a/samples/boot/oauth2resourceserver-static/README.adoc b/samples/boot/oauth2resourceserver-static/README.adoc new file mode 100644 index 0000000000..ecc530d724 --- /dev/null +++ b/samples/boot/oauth2resourceserver-static/README.adoc @@ -0,0 +1,82 @@ += OAuth 2.0 Resource Server Sample + +This sample demonstrates integrating Resource Server with a pre-configured key. + +With it, you can run the integration tests or run the application as a stand-alone service to explore how you can +secure your own service with OAuth 2.0 Bearer Tokens using Spring Security. + +== 1. Running the tests + +To run the tests, do: + +```bash +./gradlew integrationTest +``` + +Or import the project into your IDE and run `OAuth2ResourceServerApplicationITests` from there. + +=== What is it doing? + +By default, the application is configured with an RSA public key that is available in the sample. + +The tests are configured with a set of hard-coded tokens that are signed with the corresponding RSA private key. +Each test makes a query to the Resource Server with their corresponding token. + +The Resource Server subsequently verifies the token against the public key and authorizes the request, returning the phrase + +```bash +Hello, subject! +``` + +where "subject" is the value of the `sub` field in the token. + +== 2. Running the app + +To run as a stand-alone application, do: + +```bash +./gradlew bootRun +``` + +Or import the project into your IDE and run `OAuth2ResourceServerApplication` from there. + +Once it is up, you can use the following token: + +```bash +export TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdWJqZWN0IiwiaWF0IjoxNTE2MjM5MDIyfQ.eB2c9xtg5wcCZxZ-o-sH4Mx1JGkqAZwH4_WS0UcDbj_nen0NPBj6CqOEPhr_LZDagb4mM6HoAPJywWWG8b_Ylnn5r2gWDzib2mb0kxIuAjnvVBrpzusw4ItTVvP_srv2DrwcisKYiKqU5X_3ka7MSVvKtswdLY3RXeCJ_S2W9go +``` + +And then make this request: + +```bash +curl -H "Authorization: Bearer $TOKEN" localhost:8080 +``` + +Which will respond with the phrase: + +```bash +Hello, subject! +``` + +where `subject` is the value of the `sub` field in the token. + +Or this: + +```bash +export TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdWJqZWN0IiwiaWF0IjoxNTE2MjM5MDIyLCJzY29wZSI6Im1lc3NhZ2U6cmVhZCJ9.bsRCpUEaiWnzX4OqNxTBqwUD4vxxtPp-CHKTw7XcrglrvZ2lvYXaiZZbCp-hcPhuzMEzEAFuH6s4GZZOWVIX-wT47GdTz9cfA-Z4QPjS2RxePKphFXgBI3jHEpQo94Qya2fJdV4LvgBmA1uM_RTnYY1UbmeYuHKnXrZoGyV8QQQ + +curl -H "Authorization: Bearer $TOKEN" localhost:8080/message +``` + +Will respond with: + +```bash +secret message +``` + +== 3. Testing with Other Tokens + +You can create your own tokens. Simply edit the public key in `OAuth2ResourceServerSecurityConfiguration` to match the private key you use. + +To use the `/` endpoint, any valid token will do. +To use the `/message` endpoint, the token should have the `message:read` scope. diff --git a/samples/boot/oauth2resourceserver-static/spring-security-samples-boot-oauth2resourceserver-static.gradle b/samples/boot/oauth2resourceserver-static/spring-security-samples-boot-oauth2resourceserver-static.gradle new file mode 100644 index 0000000000..faba0d5f46 --- /dev/null +++ b/samples/boot/oauth2resourceserver-static/spring-security-samples-boot-oauth2resourceserver-static.gradle @@ -0,0 +1,12 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + compile project(':spring-security-config') + compile project(':spring-security-oauth2-jose') + compile project(':spring-security-oauth2-resource-server') + + compile 'org.springframework.boot:spring-boot-starter-web' + + testCompile project(':spring-security-test') + testCompile 'org.springframework.boot:spring-boot-starter-test' +} diff --git a/samples/boot/oauth2resourceserver-static/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java b/samples/boot/oauth2resourceserver-static/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java new file mode 100644 index 0000000000..734c2d5329 --- /dev/null +++ b/samples/boot/oauth2resourceserver-static/src/integration-test/java/sample/OAuth2ResourceServerApplicationITests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for {@link OAuth2ResourceServerApplication} + * + * @author Josh Cummings + */ +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class OAuth2ResourceServerApplicationITests { + + String noScopesToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdWJqZWN0IiwiaWF0IjoxNTE2MjM5MDIyfQ.eB2c9xtg5wcCZxZ-o-sH4Mx1JGkqAZwH4_WS0UcDbj_nen0NPBj6CqOEPhr_LZDagb4mM6HoAPJywWWG8b_Ylnn5r2gWDzib2mb0kxIuAjnvVBrpzusw4ItTVvP_srv2DrwcisKYiKqU5X_3ka7MSVvKtswdLY3RXeCJ_S2W9go"; + String messageReadToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdWJqZWN0IiwiaWF0IjoxNTE2MjM5MDIyLCJzY29wZSI6Im1lc3NhZ2U6cmVhZCJ9.bsRCpUEaiWnzX4OqNxTBqwUD4vxxtPp-CHKTw7XcrglrvZ2lvYXaiZZbCp-hcPhuzMEzEAFuH6s4GZZOWVIX-wT47GdTz9cfA-Z4QPjS2RxePKphFXgBI3jHEpQo94Qya2fJdV4LvgBmA1uM_RTnYY1UbmeYuHKnXrZoGyV8QQQ"; + + @Autowired + MockMvc mvc; + + @Test + public void performWhenValidBearerTokenThenAllows() + throws Exception { + + this.mvc.perform(get("/").with(bearerToken(this.noScopesToken))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Hello, subject!"))); + } + + // -- tests with scopes + + @Test + public void performWhenValidBearerTokenThenScopedRequestsAlsoWork() + throws Exception { + + this.mvc.perform(get("/message").with(bearerToken(this.messageReadToken))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("secret message"))); + } + + @Test + public void performWhenInsufficientlyScopedBearerTokenThenDeniesScopedMethodAccess() + throws Exception { + + this.mvc.perform(get("/message").with(bearerToken(this.noScopesToken))) + .andExpect(status().isForbidden()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, + containsString("Bearer error=\"insufficient_scope\""))); + } + + private static class BearerTokenRequestPostProcessor implements RequestPostProcessor { + private String token; + + public BearerTokenRequestPostProcessor(String token) { + this.token = token; + } + + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + request.addHeader("Authorization", "Bearer " + this.token); + return request; + } + } + + private static BearerTokenRequestPostProcessor bearerToken(String token) { + return new BearerTokenRequestPostProcessor(token); + } +} diff --git a/samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerApplication.java b/samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerApplication.java new file mode 100644 index 0000000000..f9cc432b1b --- /dev/null +++ b/samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerApplication.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Josh Cummings + */ +@SpringBootApplication +public class OAuth2ResourceServerApplication { + + public static void main(String[] args) { + SpringApplication.run(OAuth2ResourceServerApplication.class, args); + } +} diff --git a/samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerController.java b/samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerController.java new file mode 100644 index 0000000000..9cb92c21d6 --- /dev/null +++ b/samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerController.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author Josh Cummings + */ +@RestController +public class OAuth2ResourceServerController { + + @GetMapping("/") + public String index(@AuthenticationPrincipal Jwt jwt) { + return String.format("Hello, %s!", jwt.getSubject()); + } + + @GetMapping("/message") + public String message() { + return "secret message"; + } +} diff --git a/samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java b/samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java new file mode 100644 index 0000000000..aca6f1b8b2 --- /dev/null +++ b/samples/boot/oauth2resourceserver-static/src/main/java/sample/OAuth2ResourceServerSecurityConfiguration.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample; + +import java.security.KeyFactory; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtProcessors; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +/** + * @author Josh Cummings + */ +@EnableWebSecurity +public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfigurerAdapter { + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .antMatchers("/message/**").hasAuthority("SCOPE_message:read") + .anyRequest().authenticated() + .and() + .oauth2ResourceServer() + .jwt() + .decoder(jwtDecoder()); + // @formatter:on + } + + @Bean + JwtDecoder jwtDecoder() throws Exception { + return new NimbusJwtDecoder(JwtProcessors.withPublicKey(key()).build()); + } + + private RSAPublicKey key() throws Exception { + String encoded = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugd" + + "UWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQs" + + "HUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5D" + + "o2kQ+X5xK9cipRgEKwIDAQAB"; + byte[] bytes = Base64.getDecoder().decode(encoded.getBytes()); + return (RSAPublicKey) KeyFactory.getInstance("RSA") + .generatePublic(new X509EncodedKeySpec(bytes)); + } +}