diff --git a/spring-boot-demo-oauth/pom.xml b/spring-boot-demo-oauth/pom.xml index 724e86a..dd76db3 100644 --- a/spring-boot-demo-oauth/pom.xml +++ b/spring-boot-demo-oauth/pom.xml @@ -7,6 +7,7 @@ 1.0.0-SNAPSHOT spring-boot-demo-oauth-authorization-server + spring-boot-demo-oauth-resource-server pom @@ -26,32 +27,6 @@ - - org.springframework.boot - spring-boot-starter-web - - - - org.springframework.boot - spring-boot-starter-security - - - - org.springframework.boot - spring-boot-starter-thymeleaf - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - org.springframework.security.oauth.boot - spring-security-oauth2-autoconfigure - ${spring.boot.version} - - mysql mysql-connector-java diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/pom.xml b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/pom.xml index cfc942f..d4fff86 100644 --- a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/pom.xml +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/pom.xml @@ -11,5 +11,34 @@ spring-boot-demo-oauth-authorization-server + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.security.oauth.boot + spring-security-oauth2-autoconfigure + ${spring.boot.version} + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/application.yml b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/application.yml index d68c1b2..edbe405 100644 --- a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/application.yml +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: datasource: - url: jdbc:mysql://localhost:3306/oauth + url: jdbc:mysql://localhost:3306/oauth?allowPublicKeyRetrieval=true username: root password: 123456 hikari: diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/login.html b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/login.html index 5355e7e..896327e 100644 --- a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/login.html +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/login.html @@ -43,7 +43,7 @@ {{previousText}} 下一步 - 登录 + 登录 diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/README.adoc b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/README.adoc new file mode 100644 index 0000000..4083136 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/README.adoc @@ -0,0 +1,59 @@ += spring-boot-demo-oauth-resource-server +Doc Writer +v1.0, 2019-01-09 +:toc: + +spring boot oauth2 资源服务器,同 授权服务器 一起使用。 + +> 使用 `spring security oauth` + +- JWT 解密,远程公钥获取 +- 基于角色访问控制 +- 基于应用授权域访问控制 + +== jwt 解密 + +要先获取 jwt 公钥 + +[source,java] +.OauthResourceTokenConfig +---- +public class OauthResourceTokenConfig { + // ...... + private String getPubKey() { + // 如果本地没有密钥,就从授权服务器中获取 + return StringUtils.isEmpty(resourceServerProperties.getJwt().getKeyValue()) + ? getKeyFromAuthorizationServer() + : resourceServerProperties.getJwt().getKeyValue(); + } + // ...... +} +---- + +然后配置进去 + +[source, java] +.OauthResourceServerConfig +---- +public class OauthResourceServerConfig extends ResourceServerConfigurerAdapter { + @Override + public void configure(ResourceServerSecurityConfigurer resources) { + resources + .tokenStore(tokenStore) + .resourceId(resourceServerProperties.getResourceId()); + } +} +---- + +== 访问控制 + +通过 `@EnableGlobalMethodSecurity(prePostEnabled = true)` 注解开启 `spring security` 的全局方法安全控制 + +- `@PreAuthorize("hasRole('ADMIN')")` 校验角色 +- `@PreAuthorize("#oauth2.hasScope('READ')")` 校验令牌授权域 + +== 测试 + +测试用例: `com.xkcoding.oauth.controller.TestControllerTest` + +先获取 `token`,携带 `token` 去访问资源即可。 diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/pom.xml b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/pom.xml new file mode 100644 index 0000000..b19d74c --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/pom.xml @@ -0,0 +1,31 @@ + + + + spring-boot-demo-oauth + com.xkcoding + 1.0.0-SNAPSHOT + + 4.0.0 + + spring-boot-demo-oauth-resource-server + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.security.oauth.boot + spring-security-oauth2-autoconfigure + ${spring.boot.version} + + + diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/SpringBootDemoResourceApplication.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/SpringBootDemoResourceApplication.java new file mode 100644 index 0000000..33b7bd9 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/SpringBootDemoResourceApplication.java @@ -0,0 +1,21 @@ +package com.xkcoding.oauth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; + +/** + * 启动器. + * + * @author EchoCow + * @date 2020/1/9 上午11:38 + * @version V1.0 + */ +@EnableResourceServer +@SpringBootApplication +public class SpringBootDemoResourceApplication { + public static void main(String[] args) { + SpringApplication.run(SpringBootDemoResourceApplication.class, args); + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/config/OauthResourceServerConfig.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/config/OauthResourceServerConfig.java new file mode 100644 index 0000000..2d3243e --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/config/OauthResourceServerConfig.java @@ -0,0 +1,43 @@ +package com.xkcoding.oauth.config; + +import lombok.AllArgsConstructor; +import org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; +import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; +import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; +import org.springframework.security.oauth2.provider.token.TokenStore; + +/** + * 资源服务器配置. + * 我们自己实现了它的配置,所以它的自动装配不会生效 + * + * @author EchoCow + * @date 2020/1/9 下午2:20 + */ +@Configuration +@AllArgsConstructor +@EnableResourceServer +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class OauthResourceServerConfig extends ResourceServerConfigurerAdapter { + + private final ResourceServerProperties resourceServerProperties; + private final TokenStore tokenStore; + + @Override + public void configure(ResourceServerSecurityConfigurer resources) { + resources + .tokenStore(tokenStore) + .resourceId(resourceServerProperties.getResourceId()); + } + + @Override + public void configure(HttpSecurity http) throws Exception { + super.configure(http); + // 前后端分离下,可以关闭 csrf + http.csrf().disable(); + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/config/OauthResourceTokenConfig.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/config/OauthResourceTokenConfig.java new file mode 100644 index 0000000..c28c72c --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/config/OauthResourceTokenConfig.java @@ -0,0 +1,102 @@ +package com.xkcoding.oauth.config; + +import cn.hutool.json.JSONObject; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.security.oauth2.provider.token.TokenStore; +import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; +import org.springframework.security.oauth2.provider.token.store.JwtTokenStore; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.util.Base64; + +/** + * token 相关配置,jwt 相关. + * + * @author EchoCow + * @date 2020/1/9 下午2:39 + */ +@Slf4j +@Configuration +@AllArgsConstructor +public class OauthResourceTokenConfig { + + private final ResourceServerProperties resourceServerProperties; + + /** + * 这里并不是对令牌的存储,他将访问令牌与身份验证进行转换 + * 在需要 {@link TokenStore} 的任何地方可以使用此方法 + * + * @return TokenStore + */ + @Bean + public TokenStore tokenStore() { + return new JwtTokenStore(jwtAccessTokenConverter()); + } + + /** + * jwt 令牌转换 + * + * @return jwt + */ + @Bean + public JwtAccessTokenConverter jwtAccessTokenConverter() { + JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); + converter.setVerifierKey(getPubKey()); + return converter; + } + + /** + * 非对称密钥加密,获取 public key。 + * 自动选择加载方式。 + * + * @return public key + */ + private String getPubKey() { + // 如果本地没有密钥,就从授权服务器中获取 + return StringUtils.isEmpty(resourceServerProperties.getJwt().getKeyValue()) + ? getKeyFromAuthorizationServer() + : resourceServerProperties.getJwt().getKeyValue(); + } + + /** + * 本地没有公钥的时候,从服务器上获取 + * 需要进行 Basic 认证 + * + * @return public key + */ + private String getKeyFromAuthorizationServer() { + ObjectMapper objectMapper = new ObjectMapper(); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add(HttpHeaders.AUTHORIZATION, encodeClient()); + HttpEntity requestEntity = new HttpEntity<>(null, httpHeaders); + String pubKey = new RestTemplate() + .getForObject(resourceServerProperties.getJwt().getKeyUri(), String.class, requestEntity); + try { + JSONObject body = objectMapper.readValue(pubKey, JSONObject.class); + log.info("Get Key From Authorization Server."); + return body.getStr("value"); + } catch (IOException e) { + log.error("Get public key error: {}", e.getMessage()); + } + return null; + } + + /** + * 客户端信息 + * + * @return basic + */ + private String encodeClient() { + return "Basic " + Base64.getEncoder().encodeToString((resourceServerProperties.getClientId() + + ":" + resourceServerProperties.getClientSecret()).getBytes()); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/controller/TestController.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/controller/TestController.java new file mode 100644 index 0000000..9c6ed62 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/controller/TestController.java @@ -0,0 +1,60 @@ +package com.xkcoding.oauth.controller; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 测试接口. + * + * @author EchoCow + * @date 2020/1/9 下午2:37 + */ +@RestController +public class TestController { + + /** + * 拥有 ROLE_ADMIN 的用户才能访问的资源 + * + * @return ADMIN + */ + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/admin") + public String admin() { + return "ADMIN"; + } + + /** + * 拥有 ROLE_TEST 的用户才能访问的资源 + * + * @return TEST + */ + @PreAuthorize("hasRole('TEST')") + @GetMapping("/test") + public String test() { + return "TEST"; + } + + /** + * scope 有 READ 的用户资源才能访问 + * + * @return READ + */ + @PreAuthorize("#oauth2.hasScope('READ')") + @GetMapping("/read") + public String read() { + return "READ"; + } + + /** + * scope 有 WRITE 的用户资源才能访问 + * + * @return WRITE + */ + @PreAuthorize("#oauth2.hasScope('WRITE')") + @GetMapping("/write") + public String write() { + return "WRITE"; + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/resources/application.yml b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/resources/application.yml new file mode 100644 index 0000000..9d6558a --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/resources/application.yml @@ -0,0 +1,30 @@ +server: + port: 8081 +security: + oauth2: + resource: + token-info-uri: http://localhost:8080/oauth/check_token + jwt: + key-alias: oauth2 + # 如果没有此项会去请求授权服务器获取 + key-value: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkF9SyMHeGAsLMwbPsKj/ + xpEtS0iCe8vTSBnIGBDZKmB3ma20Ry0Uzn3m+f40RwCXlxnUcvTw7ipoz0tMQERQ + b3X4DkYCJXPK6pAD+R9/J5odEwrO2eysByWfcbMjsZw2u5pH5hleMS0YqkrGQOxJ + pzlEcKxMePU5KYTbKUJkhOYPY+gQr61g6lF97WggSPtuQn1srT+Ptvfw6yRC4bdI + 0zV5emfXjmoLUwaQTRoGYhOFrm97vpoKiltSNIDFW01J1Lr+l77ddDFC6cdiAC0H + 5/eENWBBBTFWya8RlBTzHuikfFS1gP49PZ6MYJIVRs8p9YnnKTy7TVcGKY3XZMCA + mwIDAQAB + -----END PUBLIC KEY----- + key-uri: http://localhost:8080/oauth/token_key + id: oauth2 + client: + client-id: oauth2 + client-secret: oauth2 + access-token-uri: http://localhost:8080/oauth/token + scope: READ + +logging: + level: + org.springframework.security: debug diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/test/java/com/xkcoding/oauth/AuthorizationTest.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/test/java/com/xkcoding/oauth/AuthorizationTest.java new file mode 100644 index 0000000..774a4ec --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/test/java/com/xkcoding/oauth/AuthorizationTest.java @@ -0,0 +1,38 @@ +package com.xkcoding.oauth; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.OAuth2RestTemplate; +import org.springframework.security.oauth2.client.token.grant.password.ResourceOwnerPasswordResourceDetails; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * . + * + * @author EchoCow + * @date 2020/1/9 下午3:44 + */ +public class AuthorizationTest { + public static final String AUTHORIZATION_SERVER = "http://127.0.0.1:8080"; + + protected OAuth2RestTemplate oauth2RestTemplate(String username, String password, List scope) { + ResourceOwnerPasswordResourceDetails resource = new ResourceOwnerPasswordResourceDetails(); + resource.setAccessTokenUri(AUTHORIZATION_SERVER + "/oauth/token"); + resource.setClientId("oauth2"); + resource.setClientSecret("oauth2"); + resource.setId("oauth2"); + resource.setScope(scope); + resource.setUsername(username); + resource.setPassword(password); + return new OAuth2RestTemplate(resource); + } + + @Test + void testAccessTokenWhenPassed() { + assertNotNull(oauth2RestTemplate("admin", "123456", Collections.singletonList("READ")) + .getAccessToken()); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/test/java/com/xkcoding/oauth/controller/TestControllerTest.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/test/java/com/xkcoding/oauth/controller/TestControllerTest.java new file mode 100644 index 0000000..ea0a432 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/test/java/com/xkcoding/oauth/controller/TestControllerTest.java @@ -0,0 +1,83 @@ +package com.xkcoding.oauth.controller; + +import com.xkcoding.oauth.AuthorizationTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.OAuth2RestTemplate; +import org.springframework.security.oauth2.client.resource.OAuth2AccessDeniedException; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.http.HttpMethod.GET; + +/** + * . + * + * @author EchoCow + * @date 2020/1/9 下午3:46 + */ +public class TestControllerTest extends AuthorizationTest { + + private static final String URL = "http://127.0.0.1:8081"; + + @Test + @DisplayName("ROLE_ADMIN 角色测试") + void testAdminRoleSucceedAndTestRoleFailedWhenPassed() { + OAuth2RestTemplate template = oauth2RestTemplate("admin", "123456", Collections.singletonList("READ")); + ResponseEntity response = template.exchange(URL + "/admin", GET, null, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("ADMIN", response.getBody()); + assertThrows(OAuth2AccessDeniedException.class, + () -> template.exchange(URL + "/test", GET, null, String.class)); + } + + @Test + @DisplayName("ROLE_Test 角色测试") + void testTestRoleSucceedWhenPassed() { + OAuth2RestTemplate template = oauth2RestTemplate("test", "123456", Collections.singletonList("READ")); + ResponseEntity response = template.exchange(URL + "/test", GET, null, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("TEST", response.getBody()); + assertThrows(OAuth2AccessDeniedException.class, + () -> template.exchange(URL + "/admin", GET, null, String.class)); + } + + @Test + @DisplayName("SCOPE_READ 授权域测试") + void testScopeReadWhenPassed() { + OAuth2RestTemplate template = oauth2RestTemplate("admin", "123456", Collections.singletonList("READ")); + ResponseEntity response = template.exchange(URL + "/read", GET, null, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("READ", response.getBody()); + assertThrows(OAuth2AccessDeniedException.class, + () -> template.exchange(URL + "/write", GET, null, String.class)); + } + + @Test + @DisplayName("SCOPE_WRITE 授权域测试") + void testScopeWriteWhenPassed() { + OAuth2RestTemplate template = oauth2RestTemplate("admin", "123456", Collections.singletonList("WRITE")); + ResponseEntity response = template.exchange(URL + "/write", GET, null, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("WRITE", response.getBody()); + assertThrows(OAuth2AccessDeniedException.class, + () -> template.exchange(URL + "/read", GET, null, String.class)); + } + + @Test + @DisplayName("SCOPE 测试") + void testScopeWhenPassed() { + OAuth2RestTemplate template = oauth2RestTemplate("admin", "123456", Arrays.asList("READ", "WRITE")); + ResponseEntity writeResponse = template.exchange(URL + "/write", GET, null, String.class); + assertEquals(HttpStatus.OK, writeResponse.getStatusCode()); + assertEquals("WRITE", writeResponse.getBody()); + ResponseEntity readResponse = template.exchange(URL + "/read", GET, null, String.class); + assertEquals(HttpStatus.OK, readResponse.getStatusCode()); + assertEquals("READ", readResponse.getBody()); + } +}