# Conflicts: # spring-boot-demo-oauth/pom.xml # spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/pom.xml # spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/application.yml # spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/login.htmlpull/1/head
| @@ -7,6 +7,7 @@ | |||
| <version>1.0.0-SNAPSHOT</version> | |||
| <modules> | |||
| <module>spring-boot-demo-oauth-authorization-server</module> | |||
| <module>spring-boot-demo-oauth-resource-server</module> | |||
| </modules> | |||
| <packaging>pom</packaging> | |||
| @@ -26,32 +27,6 @@ | |||
| </properties> | |||
| <dependencies> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-web</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-security</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-thymeleaf</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-data-jpa</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.security.oauth.boot</groupId> | |||
| <artifactId>spring-security-oauth2-autoconfigure</artifactId> | |||
| <version>${spring.boot.version}</version> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>mysql</groupId> | |||
| <artifactId>mysql-connector-java</artifactId> | |||
| @@ -11,5 +11,34 @@ | |||
| <artifactId>spring-boot-demo-oauth-authorization-server</artifactId> | |||
| <dependencies> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-web</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-security</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.security.oauth.boot</groupId> | |||
| <artifactId>spring-security-oauth2-autoconfigure</artifactId> | |||
| <version>${spring.boot.version}</version> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-thymeleaf</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-data-jpa</artifactId> | |||
| </dependency> | |||
| </dependencies> | |||
| </project> | |||
| @@ -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: | |||
| @@ -43,7 +43,7 @@ | |||
| <v-btn outlined color="info" @click="previous">{{previousText}}</v-btn> | |||
| <v-spacer></v-spacer> | |||
| <v-btn color="info" type="button" @click="next" v-show="window === 0">下一步</v-btn> | |||
| <v-btn color="info" type="submit" v-show="window === 1">登录</v-btn> | |||
| <v-btn color="info" type="submit" @click="next" v-show="window === 1">登录</v-btn> | |||
| </v-card-actions> | |||
| </v-form> | |||
| </v-card> | |||
| @@ -0,0 +1,59 @@ | |||
| = spring-boot-demo-oauth-resource-server | |||
| Doc Writer <lzy@echocow.cn> | |||
| 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` 去访问资源即可。 | |||
| @@ -0,0 +1,31 @@ | |||
| <?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"> | |||
| <parent> | |||
| <artifactId>spring-boot-demo-oauth</artifactId> | |||
| <groupId>com.xkcoding</groupId> | |||
| <version>1.0.0-SNAPSHOT</version> | |||
| </parent> | |||
| <modelVersion>4.0.0</modelVersion> | |||
| <artifactId>spring-boot-demo-oauth-resource-server</artifactId> | |||
| <dependencies> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-web</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-security</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.security.oauth.boot</groupId> | |||
| <artifactId>spring-security-oauth2-autoconfigure</artifactId> | |||
| <version>${spring.boot.version}</version> | |||
| </dependency> | |||
| </dependencies> | |||
| </project> | |||
| @@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
| * @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); | |||
| } | |||
| } | |||
| @@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
| * @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(); | |||
| } | |||
| } | |||
| @@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
| * @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<String> 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()); | |||
| } | |||
| } | |||
| @@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
| * @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"; | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
| * @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<String> 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()); | |||
| } | |||
| } | |||
| @@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
| * @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<String> 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<String> 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<String> 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<String> 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<String> writeResponse = template.exchange(URL + "/write", GET, null, String.class); | |||
| assertEquals(HttpStatus.OK, writeResponse.getStatusCode()); | |||
| assertEquals("WRITE", writeResponse.getBody()); | |||
| ResponseEntity<String> readResponse = template.exchange(URL + "/read", GET, null, String.class); | |||
| assertEquals(HttpStatus.OK, readResponse.getStatusCode()); | |||
| assertEquals("READ", readResponse.getBody()); | |||
| } | |||
| } | |||