@@ -7,6 +7,7 @@ | |||||
<version>1.0.0-SNAPSHOT</version> | <version>1.0.0-SNAPSHOT</version> | ||||
<modules> | <modules> | ||||
<module>spring-boot-demo-oauth-authorization-server</module> | <module>spring-boot-demo-oauth-authorization-server</module> | ||||
<module>spring-boot-demo-oauth-resource-server</module> | |||||
</modules> | </modules> | ||||
<packaging>pom</packaging> | <packaging>pom</packaging> | ||||
@@ -26,31 +27,6 @@ | |||||
</properties> | </properties> | ||||
<dependencies> | <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> | <dependency> | ||||
<groupId>mysql</groupId> | <groupId>mysql</groupId> | ||||
@@ -11,5 +11,34 @@ | |||||
<artifactId>spring-boot-demo-oauth-authorization-server</artifactId> | <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> | </project> |
@@ -3,7 +3,7 @@ server: | |||||
spring: | spring: | ||||
datasource: | datasource: | ||||
url: jdbc:mysql://localhost:3306/oauth | |||||
url: jdbc:mysql://localhost:3306/oauth?allowPublicKeyRetrieval=true | |||||
username: root | username: root | ||||
password: 123456 | password: 123456 | ||||
hikari: | hikari: | ||||
@@ -43,7 +43,7 @@ | |||||
<v-btn outlined color="info" @click="previous">{{previousText}}</v-btn> | <v-btn outlined color="info" @click="previous">{{previousText}}</v-btn> | ||||
<v-spacer></v-spacer> | <v-spacer></v-spacer> | ||||
<v-btn color="info" type="button" @click="next" v-show="window === 0">下一步</v-btn> | <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-card-actions> | ||||
</v-form> | </v-form> | ||||
</v-card> | </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()); | |||||
} | |||||
} |