From dcfeb4f4dec08af75419aa9b8d517cc83a6c012b Mon Sep 17 00:00:00 2001 From: EchoCow Date: Tue, 7 Jan 2020 16:17:56 +0800 Subject: [PATCH 1/2] :sparkles: Add spring-boot-demo-oauth-authorization-server. --- pom.xml | 14 + spring-boot-demo-oauth/pom.xml | 52 +++- .../README.adoc | 273 ++++++++++++++++++ .../image/Code.png | Bin 0 -> 29672 bytes .../image/Confirm.png | Bin 0 -> 22681 bytes .../image/Login.png | Bin 0 -> 22048 bytes .../image/Logout.png | Bin 0 -> 24467 bytes .../pom.xml | 15 + .../oauth/SpringBootDemoOauthApplication.java | 3 +- .../config/ClientLoginFailureHandler.java | 31 ++ .../config/ClientLogoutSuccessHandler.java | 30 ++ .../Oauth2AuthorizationServerConfig.java | 54 ++++ .../Oauth2AuthorizationTokenConfig.java | 74 +++++ .../oauth/config/WebSecurityConfig.java | 54 ++++ .../xkcoding/oauth/config/package-info.java | 22 ++ .../controller/AuthorizationController.java | 43 +++ .../oauth/controller/Oauth2Controller.java | 55 ++++ .../oauth/controller/package-info.java | 14 + .../oauth/entity/SysClientDetails.java | 191 ++++++++++++ .../com/xkcoding/oauth/entity/SysRole.java | 49 ++++ .../com/xkcoding/oauth/entity/SysUser.java | 55 ++++ .../SysClientDetailsRepository.java | 33 +++ .../oauth/repostiory/SysUserRepository.java | 24 ++ .../service/SysClientDetailsService.java | 67 +++++ .../oauth/service/SysUserService.java | 59 ++++ .../impl/SysClientDetailsServiceImpl.java | 73 +++++ .../service/impl/SysUserServiceImpl.java | 76 +++++ .../xkcoding/oauth/service/package-info.java | 7 + .../src/main/resources/application.yml | 22 ++ .../src/main/resources/oauth2.jks | Bin 0 -> 2559 bytes .../src/main/resources/public.txt | 9 + .../resources/templates/authorization.html | 55 ++++ .../resources/templates/common/common.html | 33 +++ .../src/main/resources/templates/error.html | 45 +++ .../src/main/resources/templates/login.html | 110 +++++++ .../src/main/resources/templates/logout.html | 44 +++ .../resources/templates/registerTemplate.html | 155 ++++++++++ .../xkcoding/oauth/PasswordEncodeTest.java | 22 ++ .../oauth/AuthorizationCodeGrantTests.java | 125 ++++++++ .../oauth/oauth/AuthorizationServerInfo.java | 94 ++++++ .../ResourceOwnerPasswordGrantTests.java | 39 +++ .../repostiory/SysClientDetailsTest.java | 26 ++ .../repostiory/SysUserRepositoryTest.java | 40 +++ .../src/test/resources/application.yml | 21 ++ .../src/test/resources/import.sql | 10 + .../src/test/resources/schema.sql | 40 +++ .../src/main/resources/application.yml | 4 - .../SpringBootDemoOauthApplicationTests.java | 17 -- 48 files changed, 2256 insertions(+), 23 deletions(-) create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/README.adoc create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Code.png create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Confirm.png create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Login.png create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Logout.png create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/pom.xml rename spring-boot-demo-oauth/{ => spring-boot-demo-oauth-authorization-server}/src/main/java/com/xkcoding/oauth/SpringBootDemoOauthApplication.java (90%) create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/ClientLoginFailureHandler.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/ClientLogoutSuccessHandler.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/Oauth2AuthorizationServerConfig.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/Oauth2AuthorizationTokenConfig.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/WebSecurityConfig.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/package-info.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/AuthorizationController.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/Oauth2Controller.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/package-info.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysClientDetails.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysRole.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysUser.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/repostiory/SysClientDetailsRepository.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/repostiory/SysUserRepository.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/SysClientDetailsService.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/SysUserService.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/impl/SysClientDetailsServiceImpl.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/impl/SysUserServiceImpl.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/package-info.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/application.yml create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/oauth2.jks create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/public.txt create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/authorization.html create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/common/common.html create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/error.html create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/login.html create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/logout.html create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/registerTemplate.html create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/PasswordEncodeTest.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/AuthorizationCodeGrantTests.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/AuthorizationServerInfo.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/ResourceOwnerPasswordGrantTests.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/repostiory/SysClientDetailsTest.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/repostiory/SysUserRepositoryTest.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/application.yml create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/import.sql create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/schema.sql delete mode 100644 spring-boot-demo-oauth/src/main/resources/application.yml delete mode 100644 spring-boot-demo-oauth/src/test/java/com/xkcoding/oauth/SpringBootDemoOauthApplicationTests.java diff --git a/pom.xml b/pom.xml index 64089db..8311573 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,20 @@ 1.20 + + + aliyun + aliyun + https://maven.aliyun.com/repository/public + + true + + + false + + + + diff --git a/spring-boot-demo-oauth/pom.xml b/spring-boot-demo-oauth/pom.xml index 8ad7b24..724e86a 100644 --- a/spring-boot-demo-oauth/pom.xml +++ b/spring-boot-demo-oauth/pom.xml @@ -5,7 +5,10 @@ spring-boot-demo-oauth 1.0.0-SNAPSHOT - jar + + spring-boot-demo-oauth-authorization-server + + pom spring-boot-demo-oauth Demo project for Spring Boot @@ -28,10 +31,49 @@ 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 + runtime + + + + com.h2database + h2 + test + + org.springframework.boot spring-boot-starter-test test + + + junit + junit + + @@ -44,6 +86,14 @@ lombok true + + + org.junit.jupiter + junit-jupiter + 5.5.2 + test + + diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/README.adoc b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/README.adoc new file mode 100644 index 0000000..1fee060 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/README.adoc @@ -0,0 +1,273 @@ += spring-boot-demo-oauth-authorization-server +Doc Writer +v1.0, 2019-01-07 +:toc: + +spring boot oauth2 授权服务器, + +- 授权码模式、密码模式、刷新令牌 +- 自定义 UserDetailService +- 自定义 ClientDetailService +- jwt 非对称加密 +- 自定义登录授权页面 + +> SQL 语句 +> +> - DDL: `src/test/resources/schema.sql` +> - DML: `src/test/resources/import.sql` + +测试用例使用 h2 数据库,测试数据如下: + +.测试客户端 +|=== +|客户端 id |客户端密钥 |资源服务器名称 |授权类型 | scopes| 回调地址 + +|oauth2 +|oauth2 +|oauth2 +|authorization_code,password,refresh_token +|READ,WRITE +|http://example.com + +|test +|oauth2 +|oauth2 +|authorization_code,password,refresh_token +|READ +|http://example.com + + +|error +|oauth2 +|test +|authorization_code,password,refresh_token +|READ +|http://example.com +|=== + +.测试用户 +|=== +|用户名 |密码 |角色 + +|admin +|123456 +|ROLE_ADMIN + +|test +|123456 +|ROLE_TEST + +|=== + +== 授权码模式 + +> 测试用例:`com.xkcoding.oauth.oauth.AuthorizationCodeGrantTests` + +=== 获取授权码 + +- 请求地址: http://localhost:8080/oauth/authorize?response_type=code&client_id=oauth2&redirect_uri=http://example.com&scope=READ +- 用户名:admin +- 密码:123456 + +image::image/Login.png[login] + +=== 确认授权 + +登录成功以后,进入确认授权页面。已经确认过的用户,不会再次要求确认。 + +image::image/Confirm.png[confirm] + +确认授权后,获取授权码 + +image::image/Code.png[code] + +=== 请求 token + +使用以下代码可以直接请求 token + +[shell] +---- +curl --location --request POST 'http://127.0.0.1:8080/oauth/token' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \ +--data-urlencode 'grant_type=authorization_code' \ +--data-urlencode 'code=GgX6QD' \ +--data-urlencode 'redirect_uri=http://example.com' \ +--data-urlencode 'client_id=oauth2' \ +--data-urlencode 'scope=READ WRITE' +---- + +得到 token + +[token] +---- +{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyIl0sInVzZXJfbmFtZSI6ImFkbWluIiwic2NvcGUiOlsiUkVBRCJdLCJleHAiOjE1NzgzODY4MTYsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iXSwianRpIjoiZjAyMDhiNTUtYTJjYS00NjI4LTg5YjEtNzI5MzY4MzAxOWNhIiwiY2xpZW50X2lkIjoib2F1dGgyIn0.RqJpsin6bMnwI57cGpODTplLeW_gtNWHo_l4SimyRLsnxpCWm5oY1EOb4qVHpXvCbhNsUj69D462P7le13OOmexysZIQhaoGZ_CbIlEp63XsCnr5nSKeX3dgQlyTUDjOUL0WUtY2lKqLCGMeX_rpVhfmSh3b7MC0Ntxq5ao-943QMXGRIeRvJgSkvfY2HBN6-zx1H6rE0wxnUfBC1M08kUkFYlSmsFchiz-E_oTzJvE2D8lA9g-eEFU6cZ_els4Q77Vvc_O6SXUZ7o65vFyLyUjLvh9QF1825SGIUUdXTUYSZjnSAXChhRIAT5pLRHK-gthIzpOaWrgj6ebUoG02Eg", + "token_type": "bearer", + "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyIl0sInVzZXJfbmFtZSI6ImFkbWluIiwic2NvcGUiOlsiUkVBRCJdLCJhdGkiOiJmMDIwOGI1NS1hMmNhLTQ2MjgtODliMS03MjkzNjgzMDE5Y2EiLCJleHAiOjE1NzgzODY4MTYsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iXSwianRpIjoiMGViNTU2MTQtYjgxYS00MTFmLTg1MTAtZThkMjZmODJmMjJhIiwiY2xpZW50X2lkIjoib2F1dGgyIn0.CBGcjirkf-3187SgbZr0ikauiCS8U9YLaoR4sNlRQjd-gaIeF5PChnIs_yAmG_VpqPFlPRdSl8DA05S2QnFpT3TkRjyP-LPDZgsVAPfczMAdVywU1zOKYZeq-gM6p9bmGEabbZoBlIxOImsjeyFSCui6UtRTZjNlj3AhGIzvs52T8bDqC796iHPDZvJ97MMgsEiRyu-mxDm1o1LMuBX9RHCx9rAkBVf52q36bqWMcYAlDOu1wYjpmhalSLZyWcmraQvClEitXGJI4eTFapTnuXQuWFIL-973V_5Shw98-bk65zZQOEheazHrUf-n4h-sYT4akehnYSVxX2UIg9XsCw", + "expires_in": 5999, + "scope": "READ", + "jti": "f0208b55-a2ca-4628-89b1-7293683019ca" +} +---- + +== 密码模式 + +> 测试用例:`com.xkcoding.oauth.oauth.ResourceOwnerPasswordGrantTests` + +`test` 用户进行授权 + +[source] +---- +curl --location --request POST 'http://127.0.0.1:8080/oauth/token' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \ +--data-urlencode 'password=123456' \ +--data-urlencode 'username=test' \ +--data-urlencode 'grant_type=password' \ +--data-urlencode 'scope=READ WRITE' +---- + +== 刷新令牌 + +携带 `refresh_token` 去请求 + +[source] +---- +curl --location --request POST 'http://127.0.0.1:8080/oauth/token' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \ +--data-urlencode 'grant_type=refresh_token' \ +--data-urlencode 'refresh_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyIl0sInVzZXJfbmFtZSI6ImFkbWluIiwic2NvcGUiOlsiUkVBRCJdLCJhdGkiOiJmMDIwOGI1NS1hMmNhLTQ2MjgtODliMS03MjkzNjgzMDE5Y2EiLCJleHAiOjE1NzgzODY4MTYsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iXSwianRpIjoiMGViNTU2MTQtYjgxYS00MTFmLTg1MTAtZThkMjZmODJmMjJhIiwiY2xpZW50X2lkIjoib2F1dGgyIn0.CBGcjirkf-3187SgbZr0ikauiCS8U9YLaoR4sNlRQjd-gaIeF5PChnIs_yAmG_VpqPFlPRdSl8DA05S2QnFpT3TkRjyP-LPDZgsVAPfczMAdVywU1zOKYZeq-gM6p9bmGEabbZoBlIxOImsjeyFSCui6UtRTZjNlj3AhGIzvs52T8bDqC796iHPDZvJ97MMgsEiRyu-mxDm1o1LMuBX9RHCx9rAkBVf52q36bqWMcYAlDOu1wYjpmhalSLZyWcmraQvClEitXGJI4eTFapTnuXQuWFIL-973V_5Shw98-bk65zZQOEheazHrUf-n4h-sYT4akehnYSVxX2UIg9XsCw' +---- + +== 解析令牌 + +携带令牌解析 + +[source] +---- +curl --location --request POST 'http://127.0.0.1:8080/oauth/check_token' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \ +--data-urlencode 'token=' +---- + +解析结果 + +[source] +---- +{ + "aud": [ + "oauth2" + ], + "user_name": "admin", + "scope": [ + "READ", + "WRITE" + ], + "active": true, + "exp": 1578389936, + "authorities": [ + "ROLE_ADMIN" + ], + "jti": "fe59fce9-6764-435e-8fa7-7320e11af811", + "client_id": "oauth2" +} +---- + +== 退出登录 + +授权码模式登陆是在授权服务器上登录的,所以退出也要在授权服务器上退出。 + +携带回调地址进行退出,退出完成后跳转到回调地址: + +image::image/Logout.png[logout] + +退出以后自动跳转到回调地址(要加 `http` 或 `https`) + +== 获取公钥 + +通过访问 '/oauth/token_key' 获取 JWT 公钥 + +[source] +---- +curl --location --request GET 'http://127.0.0.1:8080/oauth/token_key' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' +---- + +获取后 + +[source] +---- +{ + "alg": "SHA256withRSA", + "value": "-----BEGIN PUBLIC KEY-----\n......\n-----END PUBLIC KEY-----" +} +---- + +== 核心配置 + +=== 授权服务器配置 + +[Oauth2AuthorizationServerConfig] +---- +@Override +public void configure(AuthorizationServerEndpointsConfigurer endpoints) { + endpoints.authenticationManager(authenticationManager) + // 自定义用户 + .userDetailsService(sysUserService) + // 内存存储 + .tokenStore(tokenStore) + // jwt 令牌转换 + .accessTokenConverter(jwtAccessTokenConverter); +} + +@Override +public void configure(ClientDetailsServiceConfigurer clients) throws Exception { + // 从数据库读取我们自定义的客户端信息 + clients.withClientDetails(sysClientDetailsService); +} + +@Override +public void configure(AuthorizationServerSecurityConfigurer security) { + security + // 获取 token key 需要进行 basic 认证客户端信息 + .tokenKeyAccess("isAuthenticated()") + // 获取 token 信息同样需要 basic 认证客户端信息 + .checkTokenAccess("isAuthenticated()"); +} +---- + +=== 安全配置 + +[WebSecurityConfig] +---- +@Override +protected void configure(HttpSecurity http) throws Exception { + http + // 开启表单登录,授权码模式的时候进行登录 + .formLogin() + // 路径等 + .loginPage("/oauth/login") + .loginProcessingUrl("/authorization/form") + // 失败以后携带错误信息进行再次跳转登录页面 + .failureHandler(clientLoginFailureHandler) + .and() + // 退出登录相关 + .logout() + .logoutUrl("/oauth/logout") + .logoutSuccessHandler(clientLogoutSuccessHandler) + .and() + // 授权服务器安全配置 + .authorizeRequests() + .antMatchers("/oauth/**").permitAll() + .anyRequest() + .authenticated(); +} +---- + +== 参考 + +- https://echocow.cn/articles/2019/07/14/1563096109754.html[Spring Security Oauth2 从零到一完整实践(三)授权服务器 ] diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Code.png b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Code.png new file mode 100644 index 0000000000000000000000000000000000000000..f9de1c61140fa508fd5257bb4b0740ac945e7bcd GIT binary patch literal 29672 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 literal 0 HcmV?d00001 diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Login.png b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Login.png new file mode 100644 index 0000000000000000000000000000000000000000..b830990ebdfc95c2c5c870a1258e13a448499b39 GIT binary patch literal 22048 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

^Fm7jE^u_qgvY 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 literal 0 HcmV?d00001 diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Logout.png b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/image/Logout.png new file mode 100644 index 0000000000000000000000000000000000000000..001dd8a5d02eded699f271d3f87fd677d0d02276 GIT binary patch literal 24467 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 + + + spring-boot-demo-oauth + com.xkcoding + 1.0.0-SNAPSHOT + + 4.0.0 + + spring-boot-demo-oauth-authorization-server + + + diff --git a/spring-boot-demo-oauth/src/main/java/com/xkcoding/oauth/SpringBootDemoOauthApplication.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/SpringBootDemoOauthApplication.java similarity index 90% rename from spring-boot-demo-oauth/src/main/java/com/xkcoding/oauth/SpringBootDemoOauthApplication.java rename to spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/SpringBootDemoOauthApplication.java index 382a8b1..ed73b61 100644 --- a/spring-boot-demo-oauth/src/main/java/com/xkcoding/oauth/SpringBootDemoOauthApplication.java +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/SpringBootDemoOauthApplication.java @@ -2,7 +2,6 @@ package com.xkcoding.oauth; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.web.bind.annotation.GetMapping; /** *

@@ -16,6 +15,8 @@ import org.springframework.web.bind.annotation.GetMapping; * @copyright: Copyright (c) 2019 * @version: V1.0 * @modified: yangkai.shen + * @modified: EchoCow + * @date: Modified in 2020-01-6 21:12 */ @SpringBootApplication public class SpringBootDemoOauthApplication { diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/ClientLoginFailureHandler.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/ClientLoginFailureHandler.java new file mode 100644 index 0000000..816ab07 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/ClientLoginFailureHandler.java @@ -0,0 +1,31 @@ +package com.xkcoding.oauth.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; + +/** + * 登录失败处理器,失败后携带失败信息重定向到登录地址重新登录. + * + * @author EchoCow + * @date 2020/1/7 下午1:01 + */ +@Slf4j +@Component +public class ClientLoginFailureHandler implements AuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException { + log.debug("Login failed!"); + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.sendRedirect("/oauth/login?error=" + + URLEncoder.encode(exception.getLocalizedMessage(), "UTF-8")); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/ClientLogoutSuccessHandler.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/ClientLogoutSuccessHandler.java new file mode 100644 index 0000000..1737a63 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/ClientLogoutSuccessHandler.java @@ -0,0 +1,30 @@ +package com.xkcoding.oauth.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 客户团退出登录成功处理器. + * + * @author EchoCow + * @date 2020/1/6 下午22:11 + */ +@Slf4j +@Component +public class ClientLogoutSuccessHandler implements LogoutSuccessHandler { + + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + response.setStatus(HttpStatus.FOUND.value()); + // 跳转到客户端的回调地址 + response.sendRedirect(request.getParameter("redirectUrl")); + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/Oauth2AuthorizationServerConfig.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/Oauth2AuthorizationServerConfig.java new file mode 100644 index 0000000..787c9f3 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/Oauth2AuthorizationServerConfig.java @@ -0,0 +1,54 @@ +package com.xkcoding.oauth.config; + +import com.xkcoding.oauth.service.SysClientDetailsService; +import com.xkcoding.oauth.service.SysUserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; +import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; +import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; +import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; +import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer; +import org.springframework.security.oauth2.provider.token.TokenStore; +import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; + +/** + * . + * + * @author EchoCow + * @date 2020/1/6 下午1:32 + */ +@Configuration +@RequiredArgsConstructor +@EnableAuthorizationServer +public class Oauth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { + private final SysClientDetailsService sysClientDetailsService; + private final SysUserService sysUserService; + private final TokenStore tokenStore; + private final AuthenticationManager authenticationManager; + private final JwtAccessTokenConverter jwtAccessTokenConverter; + + @Override + public void configure(AuthorizationServerEndpointsConfigurer endpoints) { + endpoints.authenticationManager(authenticationManager) + .userDetailsService(sysUserService) + .tokenStore(tokenStore) + .accessTokenConverter(jwtAccessTokenConverter); + } + + @Override + public void configure(ClientDetailsServiceConfigurer clients) throws Exception { + // 从数据库读取我们自定义的客户端信息 + clients.withClientDetails(sysClientDetailsService); + } + + @Override + public void configure(AuthorizationServerSecurityConfigurer security) { + security + // 获取 token key 需要进行 basic 认证客户端信息 + .tokenKeyAccess("isAuthenticated()") + // 获取 token 信息同样需要 basic 认证客户端信息 + .checkTokenAccess("isAuthenticated()"); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/Oauth2AuthorizationTokenConfig.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/Oauth2AuthorizationTokenConfig.java new file mode 100644 index 0000000..39ac779 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/Oauth2AuthorizationTokenConfig.java @@ -0,0 +1,74 @@ +package com.xkcoding.oauth.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.provider.token.TokenStore; +import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore; +import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; +import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory; + +import java.security.KeyPair; + +/** + * token 相关配置. + * + * @author EchoCow + * @date 2020/1/6 下午1:33 + */ +@Configuration +@RequiredArgsConstructor +public class Oauth2AuthorizationTokenConfig { + + /** + * 声明 内存 TokenStore 实现,用来存储 token 相关. + * 默认实现有 mysql、redis + * + * @return InMemoryTokenStore + */ + @Bean + @Primary + public TokenStore tokenStore() { + return new InMemoryTokenStore(); + } + + /** + * jwt 令牌 配置,非对称加密 + * + * @return 转换器 + */ + @Bean + public JwtAccessTokenConverter jwtAccessTokenConverter() { + final JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter(); + accessTokenConverter.setKeyPair(keyPair()); + return accessTokenConverter; + } + + /** + * 密钥 keyPair. + * 可用于生成 jwt / jwk. + * + * @return keyPair + */ + @Bean + public KeyPair keyPair() { + KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("oauth2.jks"), "123456".toCharArray()); + return keyStoreKeyFactory.getKeyPair("oauth2"); + } + + /** + * 加密方式,使用 BCrypt. + * 参数越大加密次数越多,时间越久. + * 默认为 10. + * + * @return PasswordEncoder + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/WebSecurityConfig.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/WebSecurityConfig.java new file mode 100644 index 0000000..d6071cb --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/WebSecurityConfig.java @@ -0,0 +1,54 @@ +package com.xkcoding.oauth.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +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; + +/** + * 安全配置. + * + * @author EchoCow + * @date 2020/1/6 下午1:27 + */ +@EnableWebSecurity +@RequiredArgsConstructor +@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + private final ClientLogoutSuccessHandler clientLogoutSuccessHandler; + private final ClientLoginFailureHandler clientLoginFailureHandler; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .formLogin() + .loginPage("/oauth/login") + .failureHandler(clientLoginFailureHandler) + .loginProcessingUrl("/authorization/form") + .and() + .logout() + .logoutUrl("/oauth/logout") + .logoutSuccessHandler(clientLogoutSuccessHandler) + .and() + .authorizeRequests() + .antMatchers("/oauth/**").permitAll() + .anyRequest() + .authenticated(); + } + + /** + * 授权管理. + * + * @return 认证管理对象 + * @throws Exception 认证异常信息 + */ + @Override + @Bean + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/package-info.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/package-info.java new file mode 100644 index 0000000..11cfadb --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/config/package-info.java @@ -0,0 +1,22 @@ +/** + * spring security oauth2 的相关配置。 + * 使用 spring boot oauth2 自动配置。 + * {@link com.xkcoding.oauth.config.Oauth2AuthorizationServerConfig} + * 授权服务器相关的配置,主要设置授权服务器如何读取客户端、用户信息和一些端点配置 + * 可以在这里配置更多的东西,例如端点映射,token 增强等 + * + * {@link com.xkcoding.oauth.config.Oauth2AuthorizationTokenConfig} + * 授权服务器 token 相关的配置,主要设置 jwt、加密方式等信息 + * + * {@link com.xkcoding.oauth.config.ClientLogoutSuccessHandler} + * 资源服务器退出以后的处理。在授权码模式中,所有的客户端都需要跳转到授权服务器进行登录 + * 当登录成功以后跳转到回调地址,如果用户需要登出,也要跳转到授权服务器这里进行登出 + * 但是 spring security oauth2 似乎并没有这个逻辑。 + * 所以自己给登出端点加了一个 redirect_url 参数,表示登出成功以后要跳转的地址 + * 这个处理器就是来完成登出成功以后的跳转操作的。 + * + * + * @author EchoCow + * @date 2020/1/7 上午9:16 + */ +package com.xkcoding.oauth.config; diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/AuthorizationController.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/AuthorizationController.java new file mode 100644 index 0000000..8175467 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/AuthorizationController.java @@ -0,0 +1,43 @@ +package com.xkcoding.oauth.controller; + +import org.springframework.security.oauth2.provider.AuthorizationRequest; +import org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.SessionAttributes; +import org.springframework.web.servlet.ModelAndView; + +import java.util.Map; + +/** + * 自定义确认授权页面. + * 需要注意的是: 不能在代码中 setComplete,因为整个授权流程并没有结束 + * 我们只是在中途修改了它确认的一些信息而已。 + * + * @author EchoCow + * @date 2020/1/6 下午4:42 + */ +@Controller +@SessionAttributes("authorizationRequest") +public class AuthorizationController { + + /** + * 自定义确认授权页面 + * 当然你也可以使用 {@link AuthorizationEndpoint#setUserApprovalPage(String)} 方法 + * 进行设置,但是 model 就没有那么灵活了 + * + * @param model model + * @return ModelAndView + */ + @GetMapping("/oauth/confirm_access") + public ModelAndView getAccessConfirmation(Map model) { + AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest"); + ModelAndView view = new ModelAndView(); + view.setViewName("authorization"); + view.addObject("clientId", authorizationRequest.getClientId()); + // 传递 scope 过去,Set 集合 + view.addObject("scopes", authorizationRequest.getScope()); + return view; + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/Oauth2Controller.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/Oauth2Controller.java new file mode 100644 index 0000000..5d7aa5d --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/Oauth2Controller.java @@ -0,0 +1,55 @@ +package com.xkcoding.oauth.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.servlet.ModelAndView; + +import java.security.Principal; +import java.util.Objects; + +/** + * 页面控制器. + * + * @author EchoCow + * @date 2020/1/6 下午4:30 + */ +@Controller +@RequestMapping("/oauth") +@RequiredArgsConstructor +public class Oauth2Controller { + + /** + * 授权码模式跳转到登录页面 + * + * @return view + */ + @GetMapping("/login") + public String loginView() { + return "login"; + } + + /** + * 退出登录 + * + * @param redirectUrl 退出完成后的回调地址 + * @param principal 用户信息 + * @return 结果 + */ + @GetMapping("/logout") + public ModelAndView logoutView( + @RequestParam("redirect_url") String redirectUrl, Principal principal) { + if (Objects.isNull(principal)) { + throw new ResourceAccessException("请求错误,用户尚未登录"); + } + ModelAndView view = new ModelAndView(); + view.setViewName("logout"); + view.addObject("user", principal.getName()); + view.addObject("redirectUrl", redirectUrl); + return view; + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/package-info.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/package-info.java new file mode 100644 index 0000000..453b76c --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/controller/package-info.java @@ -0,0 +1,14 @@ +/** + * 控制器。除了业务逻辑的以外,提供两个控制器来帮助完成自定义: + * {@link com.xkcoding.oauth.controller.AuthorizationController} + * 自定义的授权控制器,重新设置到我们的界面中去,不使用他的默认实现 + * + * {@link com.xkcoding.oauth.controller.Oauth2Controller} + * 页面跳转的控制器,这里拿出来是因为真的可以做很多事。比如登录的时候携带点什么 + * 或者退出的时候携带什么标识,都可以。 + * + * @author EchoCow + * @date 2020/1/7 上午11:25 + * @see org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint + */ +package com.xkcoding.oauth.controller; diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysClientDetails.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysClientDetails.java new file mode 100644 index 0000000..535e366 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysClientDetails.java @@ -0,0 +1,191 @@ +package com.xkcoding.oauth.entity; + +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.provider.ClientDetails; +import org.springframework.security.oauth2.provider.client.BaseClientDetails; + +import javax.persistence.*; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 客户端信息. + * 这里实现了 ClientDetails 接口 + * 个人建议不应该在实体类里面写任何逻辑代码 + * 而为了避免实体类耦合严重不应该去实现这个接口的 + * 但是这里为了演示和 {@link SysUser} 不同的方式,所以就选择实现这个接口了 + * 另一种方式是写一个方法将它转化为默认实现 {@link BaseClientDetails} 比较好一点并且简单很多 + * + * @author EchoCow + * @date 2020/1/6 下午12:54 + */ +@Data +@Table +@Entity +public class SysClientDetails implements ClientDetails { + + /** + * 主键 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * client id + */ + private String clientId; + + /** + * client 密钥 + */ + private String clientSecret; + + /** + * 资源服务器名称 + */ + private String resourceIds; + + /** + * 授权域 + */ + private String scopes; + + /** + * 授权类型 + */ + private String grantTypes; + + /** + * 重定向地址,授权码时必填 + */ + private String redirectUrl; + + /** + * 授权信息 + */ + private String authorizations; + + /** + * 授权令牌有效时间 + */ + private Integer accessTokenValiditySeconds; + + /** + * 刷新令牌有效时间 + */ + private Integer refreshTokenValiditySeconds; + + /** + * 自动授权请求域 + */ + private String autoApproveScopes; + + /** + * 是否安全 + * + * @return 结果 + */ + @Override + public boolean isSecretRequired() { + return this.clientSecret != null; + } + + /** + * 是否有 scopes + * + * @return 结果 + */ + @Override + public boolean isScoped() { + return this.scopes != null && !this.scopes.isEmpty(); + } + + /** + * scopes + * + * @return scopes + */ + @Override + public Set getScope() { + return stringToSet(scopes); + } + + /** + * 授权类型 + * + * @return 结果 + */ + @Override + public Set getAuthorizedGrantTypes() { + return stringToSet(grantTypes); + } + + @Override + public Set getResourceIds() { + return stringToSet(resourceIds); + } + + + /** + * 获取回调地址 + * + * @return redirectUrl + */ + @Override + public Set getRegisteredRedirectUri() { + return stringToSet(redirectUrl); + } + + /** + * 这里需要提一下 + * 个人觉得这里应该是客户端所有的权限 + * 但是已经有 scope 的存在可以很好的对客户端的权限进行认证了 + * 那么在 oauth2 的四个角色中,这里就有可能是资源服务器的权限 + * 但是一般资源服务器都有自己的权限管理机制,比如拿到用户信息后做 RBAC + * 所以在 spring security 的默认实现中直接给的是空的一个集合 + * 这里我们也给他一个空的把 + * + * @return GrantedAuthority + */ + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + + /** + * 判断是否自动授权 + * + * @param scope scope + * @return 结果 + */ + @Override + public boolean isAutoApprove(String scope) { + if (autoApproveScopes == null || autoApproveScopes.isEmpty()) { + return false; + } + Set authorizationSet = stringToSet(authorizations); + for (String auto : authorizationSet) { + if ("true".equalsIgnoreCase(auto) || scope.matches(auto)) { + return true; + } + } + return false; + } + + /** + * additional information 是 spring security 的保留字段 + * 暂时用不到,直接给个空的即可 + * + * @return map + */ + @Override + public Map getAdditionalInformation() { + return Collections.emptyMap(); + } + + private Set stringToSet(String s) { + return Arrays.stream(s.split(",")).collect(Collectors.toSet()); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysRole.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysRole.java new file mode 100644 index 0000000..e6e4f69 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysRole.java @@ -0,0 +1,49 @@ +package com.xkcoding.oauth.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.codehaus.jackson.annotate.JsonIgnore; + +import javax.persistence.*; +import java.util.Set; + +/** + * 这里完全可以只用一个字段代替的 + * 但是想了想还是模拟实际的情况来把 + * 角色信息. + * + * @author EchoCow + * @date 2020/1/6 下午12:44 + */ +@Data +@Table +@Entity +@EqualsAndHashCode(exclude = {"users"}) +@ToString(exclude = "users") +public class SysRole { + + /** + * 主键. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 角色名称,按照 spring security 规范 + * 需要以 ROLE_ 开头. + */ + private String name; + + /** + * 角色描述. + */ + private String description; + + /** + * 当前角色所有用户. + */ + @ManyToMany(mappedBy = "roles", fetch = FetchType.EAGER) + private Set users; +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysUser.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysUser.java new file mode 100644 index 0000000..84a9641 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/entity/SysUser.java @@ -0,0 +1,55 @@ +package com.xkcoding.oauth.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import javax.persistence.*; +import java.util.Set; + +/** + * 用户实体. + * 避免实体类耦合,所以不去实现 {@link UserDetails} 接口 + * 因为有且只有登录加载用户的时候才会需要这个接口 + * 我们就手动构建一个 {@link User} 的默认实现就可以了 + * 实现接口的方式可以参考 {@link SysClientDetails} + * + * @author EchoCow + * @date 2020/1/6 下午12:41 + */ +@Data +@Table +@Entity +@EqualsAndHashCode(exclude = "roles") +@ToString(exclude = "roles") +public class SysUser { + + /** + * 主键. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 用户名. + */ + private String username; + + /** + * 密码. + */ + private String password; + + /** + * 当前用户所有角色. + */ + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable(name = "sys_user_role", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + private Set roles; +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/repostiory/SysClientDetailsRepository.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/repostiory/SysClientDetailsRepository.java new file mode 100644 index 0000000..1184aca --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/repostiory/SysClientDetailsRepository.java @@ -0,0 +1,33 @@ +package com.xkcoding.oauth.repostiory; + +import com.xkcoding.oauth.entity.SysClientDetails; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; + +import java.util.Optional; + +/** + * 客户端信息. + * + * @author EchoCow + * @date 2020/1/6 下午1:09 + */ +public interface SysClientDetailsRepository extends JpaRepository { + + /** + * 通过 clientId 查找客户端信息. + * + * @param clientId clientId + * @return 结果 + */ + Optional findFirstByClientId(String clientId); + + /** + * 根据客户端 id 删除客户端 + * + * @param clientId 客户端id + */ + @Modifying + void deleteByClientId(String clientId); + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/repostiory/SysUserRepository.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/repostiory/SysUserRepository.java new file mode 100644 index 0000000..a5aaff9 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/repostiory/SysUserRepository.java @@ -0,0 +1,24 @@ +package com.xkcoding.oauth.repostiory; + +import com.xkcoding.oauth.entity.SysUser; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * 用户信息仓库. + * + * @author EchoCow + * @date 2020/1/6 下午1:08 + */ +public interface SysUserRepository extends JpaRepository { + + /** + * 通过用户名查找用户. + * + * @param username 用户名 + * @return 结果 + */ + Optional findFirstByUsername(String username); + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/SysClientDetailsService.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/SysClientDetailsService.java new file mode 100644 index 0000000..408414a --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/SysClientDetailsService.java @@ -0,0 +1,67 @@ +package com.xkcoding.oauth.service; + +import com.xkcoding.oauth.entity.SysClientDetails; +import org.springframework.security.oauth2.provider.ClientAlreadyExistsException; +import org.springframework.security.oauth2.provider.ClientDetailsService; +import org.springframework.security.oauth2.provider.ClientRegistrationService; +import org.springframework.security.oauth2.provider.NoSuchClientException; + +import java.util.List; + +/** + * 声明自己的实现. + * 参见 {@link ClientRegistrationService} + * + * @author EchoCow + * @date 2020/1/6 下午1:39 + */ +public interface SysClientDetailsService extends ClientDetailsService { + + /** + * 通过客户端 id 查询 + * + * @param clientId 客户端 id + * @return 结果 + */ + SysClientDetails findByClientId(String clientId); + + /** + * 添加客户端信息. + * + * @param clientDetails 客户端信息 + * @throws ClientAlreadyExistsException 客户端已存在 + */ + void addClientDetails(SysClientDetails clientDetails) throws ClientAlreadyExistsException; + + /** + * 更新客户端信息,不包括 clientSecret. + * + * @param clientDetails 客户端信息 + * @throws NoSuchClientException 找不到客户端异常 + */ + void updateClientDetails(SysClientDetails clientDetails) throws NoSuchClientException; + + /** + * 更新客户端密钥. + * + * @param clientId 客户端 id + * @param clientSecret 客户端密钥 + * @throws NoSuchClientException 找不到客户端异常 + */ + void updateClientSecret(String clientId, String clientSecret) throws NoSuchClientException; + + /** + * 删除客户端信息. + * + * @param clientId 客户端 id + * @throws NoSuchClientException 找不到客户端异常 + */ + void removeClientDetails(String clientId) throws NoSuchClientException; + + /** + * 查询所有 + * + * @return 结果 + */ + List findAll(); +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/SysUserService.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/SysUserService.java new file mode 100644 index 0000000..6604a54 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/SysUserService.java @@ -0,0 +1,59 @@ +package com.xkcoding.oauth.service; + +import com.xkcoding.oauth.entity.SysUser; +import org.springframework.security.core.userdetails.UserDetailsService; + +import java.util.List; + + +/** + * . + * + * @author EchoCow + * @date 2020/1/6 下午3:44 + */ +public interface SysUserService extends UserDetailsService { + /** + * 查询所有用户 + * + * @return 用户 + */ + List findAll(); + + /** + * 通过 id 查询用户 + * + * @param id id + * @return 用户 + */ + SysUser findById(Long id); + + /** + * 创建用户 + * + * @param sysUser 用户 + */ + void createUser(SysUser sysUser); + + /** + * 更新用户 + * + * @param sysUser 用户 + */ + void updateUser(SysUser sysUser); + + /** + * 更新用户 密码 + * + * @param id 用户 id + * @param password 用户密码 + */ + void updatePassword(Long id, String password); + + /** + * 删除用户. + * + * @param id id + */ + void deleteUser(Long id); +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/impl/SysClientDetailsServiceImpl.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/impl/SysClientDetailsServiceImpl.java new file mode 100644 index 0000000..00e3662 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/impl/SysClientDetailsServiceImpl.java @@ -0,0 +1,73 @@ +package com.xkcoding.oauth.service.impl; + +import com.xkcoding.oauth.entity.SysClientDetails; +import com.xkcoding.oauth.repostiory.SysClientDetailsRepository; +import com.xkcoding.oauth.service.SysClientDetailsService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.provider.*; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 客户端 相关操作. + * + * @author EchoCow + * @date 2020/1/6 下午1:37 + */ +@Service +@RequiredArgsConstructor +public class SysClientDetailsServiceImpl implements SysClientDetailsService { + + private final SysClientDetailsRepository sysClientDetailsRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public ClientDetails loadClientByClientId(String id) throws ClientRegistrationException { + return sysClientDetailsRepository.findFirstByClientId(id) + .orElseThrow(() -> new ClientRegistrationException("Loading client exception.")); + } + + @Override + public SysClientDetails findByClientId(String clientId) { + return sysClientDetailsRepository.findFirstByClientId(clientId) + .orElseThrow(() -> new ClientRegistrationException("Loading client exception.")); + } + + @Override + public void addClientDetails(SysClientDetails clientDetails) throws ClientAlreadyExistsException { + clientDetails.setId(null); + if (sysClientDetailsRepository.findFirstByClientId(clientDetails.getClientId()).isPresent()) { + throw new ClientAlreadyExistsException(String.format("Client id %s already exist.", clientDetails.getClientId())); + } + sysClientDetailsRepository.save(clientDetails); + } + + @Override + public void updateClientDetails(SysClientDetails clientDetails) throws NoSuchClientException { + SysClientDetails exist = sysClientDetailsRepository.findFirstByClientId(clientDetails.getClientId()) + .orElseThrow(() -> new NoSuchClientException("No such client!")); + clientDetails.setClientSecret(exist.getClientSecret()); + sysClientDetailsRepository.save(clientDetails); + } + + @Override + public void updateClientSecret(String clientId, String clientSecret) throws NoSuchClientException { + SysClientDetails exist = sysClientDetailsRepository.findFirstByClientId(clientId) + .orElseThrow(() -> new NoSuchClientException("No such client!")); + exist.setClientSecret(passwordEncoder.encode(clientSecret)); + sysClientDetailsRepository.save(exist); + } + + @Override + public void removeClientDetails(String clientId) throws NoSuchClientException { + sysClientDetailsRepository.deleteByClientId(clientId); + } + + @Override + public List findAll() { + return sysClientDetailsRepository.findAll(); + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/impl/SysUserServiceImpl.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/impl/SysUserServiceImpl.java new file mode 100644 index 0000000..307af4d --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/impl/SysUserServiceImpl.java @@ -0,0 +1,76 @@ +package com.xkcoding.oauth.service.impl; + +import com.xkcoding.oauth.entity.SysUser; +import com.xkcoding.oauth.repostiory.SysUserRepository; +import com.xkcoding.oauth.service.SysUserService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 用户相关操作. + * + * @author EchoCow + * @date 2020/1/6 下午3:06 + */ +@Service +@RequiredArgsConstructor +public class SysUserServiceImpl implements SysUserService { + + private final SysUserRepository sysUserRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + SysUser sysUser = sysUserRepository.findFirstByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found!")); + List roles = sysUser.getRoles().stream() + .map(sysRole -> new SimpleGrantedAuthority(sysRole.getName())) + .collect(Collectors.toList()); + // 在这里手动构建 UserDetails 的默认实现 + return new User(sysUser.getUsername(), sysUser.getPassword(), roles); + } + + @Override + public List findAll() { + return sysUserRepository.findAll(); + } + + @Override + public SysUser findById(Long id) { + return sysUserRepository.findById(id) + .orElseThrow(() -> new RuntimeException("找不到用户")); + } + + @Override + public void createUser(SysUser sysUser) { + sysUser.setId(null); + sysUserRepository.save(sysUser); + } + + @Override + public void updateUser(SysUser sysUser) { + sysUser.setPassword(null); + sysUserRepository.save(sysUser); + } + + @Override + public void updatePassword(Long id, String password) { + SysUser exist = findById(id); + exist.setPassword(passwordEncoder.encode(password)); + sysUserRepository.save(exist); + } + + @Override + public void deleteUser(Long id) { + sysUserRepository.deleteById(id); + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/package-info.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/package-info.java new file mode 100644 index 0000000..45f57f5 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/java/com/xkcoding/oauth/service/package-info.java @@ -0,0 +1,7 @@ +/** + * service 层,继承并实现 spring 接口. + * + * @author EchoCow + * @date 2020/1/7 上午9:16 + */ +package com.xkcoding.oauth.service; 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 new file mode 100644 index 0000000..d68c1b2 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/application.yml @@ -0,0 +1,22 @@ +server: + port: 8080 + +spring: + datasource: + url: jdbc:mysql://localhost:3306/oauth + username: root + password: 123456 + hikari: + data-source-properties: + useSSL: false + serverTimezone: GMT+8 + useUnicode: true + characterEncoding: utf8 + jpa: + hibernate: + ddl-auto: update + show-sql: true + +logging: + level: + org.springframework.security: debug diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/oauth2.jks b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/oauth2.jks new file mode 100644 index 0000000000000000000000000000000000000000..af97322428bc077838fc24774dafc3a40db84e1f GIT binary patch literal 2559 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 + + + 确认您的授权信息 + + +

+ + + + + + + + + 确认应用的授权信息 + +
+ +
+ + + 当前应用将会获取您的以下权限: + + + + + + + + + + + + + + 确认授权 + +
+
+
+
+
+
+
+ +
+ + + diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/common/common.html b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/common/common.html new file mode 100644 index 0000000..7cc71de --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/common/common.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + +
+ + +
+ + diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/error.html b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/error.html new file mode 100644 index 0000000..df4c1bc --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/error.html @@ -0,0 +1,45 @@ + + + + 发送了点小错误 + + +
+ + + + + + + + +

404 找不到页面

+

~~~

+ 点击返回 +
+
+
+
+
+
+
+ + +
+ + + 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 new file mode 100644 index 0000000..5355e7e --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/login.html @@ -0,0 +1,110 @@ + + + + 欢迎登录 + + +
+ + + + + + + + + 欢迎登录 + + + + +

{{infoText}}

+

+
+ + + + + + + + + + + + + + + + {{previousText}} + + 下一步 + 登录 + +
+
+
+
+
+
+
+ +
+ + + diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/logout.html b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/logout.html new file mode 100644 index 0000000..1ea0a0c --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/logout.html @@ -0,0 +1,44 @@ + + + + 确认退出吗? + + +
+ + + + + + + + + 确认退出当前应用吗? + +
+ +
+ + + + + + 确认退出 + +
+
+
+
+
+
+
+ +
+ + + diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/registerTemplate.html b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/registerTemplate.html new file mode 100644 index 0000000..3fae0b9 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/main/resources/templates/registerTemplate.html @@ -0,0 +1,155 @@ + + + + + + + + + +
+
+
云课程考试平台
+
+

亲爱的用户,你好!

+ +
+
+

+ 欢迎您注册 云课程考试平台 +

+

+ 你的邮件的验证码: + 验证码
(请输入该验证码完成 验证,验证码 + + 10 分钟内有效!)

+
如果您未申请云课程学习平台 + $(type) 服务,请忽略该邮件。 +
+
+
+ +

如果仍有问题,请联系我们的管理员: 000-00000000 +

+
+
+
+ + diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/PasswordEncodeTest.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/PasswordEncodeTest.java new file mode 100644 index 0000000..3dc8233 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/PasswordEncodeTest.java @@ -0,0 +1,22 @@ +package com.xkcoding.oauth; + +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * . + * + * @author EchoCow + * @date 2020/1/6 下午3:51 + */ +public class PasswordEncodeTest { + + private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @Test + public void getPasswordWhenPassed() { + System.out.println(passwordEncoder.encode("oauth2")); + System.out.println(passwordEncoder.encode("123456")); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/AuthorizationCodeGrantTests.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/AuthorizationCodeGrantTests.java new file mode 100644 index 0000000..01e0d44 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/AuthorizationCodeGrantTests.java @@ -0,0 +1,125 @@ +package com.xkcoding.oauth.oauth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.OAuth2RestTemplate; +import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException; +import org.springframework.security.oauth2.client.token.DefaultAccessTokenRequest; +import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeAccessTokenProvider; +import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeResourceDetails; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.xkcoding.oauth.oauth.AuthorizationServerInfo.getUrl; +import static org.junit.jupiter.api.Assertions.*; + +/** + * 授权码模式测试. + * + * @author EchoCow + * @date 2020/1/6 下午8:43 + */ +public class AuthorizationCodeGrantTests { + + private AuthorizationCodeResourceDetails resource = new AuthorizationCodeResourceDetails(); + private AuthorizationServerInfo authorizationServerInfo = new AuthorizationServerInfo(); + + @BeforeEach + void setUp() { + resource.setAccessTokenUri(getUrl("/oauth/token")); + resource.setClientId("oauth2"); + resource.setId("oauth2"); + resource.setScope(Arrays.asList("READ", "WRITE")); + resource.setAccessTokenUri(getUrl("/oauth/token")); + resource.setUserAuthorizationUri(getUrl("/oauth/authorize")); + } + + @Test + void testCannotConnectWithoutToken() { + OAuth2RestTemplate template = new OAuth2RestTemplate(resource); + assertThrows(UserRedirectRequiredException.class, + () -> template.getForObject(getUrl("/oauth/me"), String.class)); + } + + @Test + void testAttemptedTokenAcquisitionWithNoRedirect() { + AuthorizationCodeAccessTokenProvider provider = new AuthorizationCodeAccessTokenProvider(); + assertThrows(UserRedirectRequiredException.class, + () -> provider.obtainAccessToken(resource, new DefaultAccessTokenRequest())); + } + + /** + * 这里不使用他提供的是因为很多地方不符合我们的需要 + * 比如 csrf,比如许多有些是自己自定义的端点这些 + * 所以只有我们一步一步的来进行测试拿到授权码 + */ + @Test + void testCodeAcquisitionWithCorrectContext() { + // 1. 请求登录页面获取 _csrf 的 value 以及 cookie + ResponseEntity page = authorizationServerInfo.getForString("/oauth/login"); + assertNotNull(page.getBody()); + String cookie = page.getHeaders().getFirst("Set-Cookie"); + HttpHeaders headers = new HttpHeaders(); + headers.set("Cookie", cookie); + Matcher matcher = Pattern.compile("(?s).*name=\"_csrf\".*?value=\"([^\"]+).*").matcher(page.getBody()); + assertTrue(matcher.find()); + + // 2. 添加表单数据 + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("username", "admin"); + form.add("password", "123456"); + form.add("_csrf", matcher.group(1)); + + // 3. 登录授权并获取登录成功的 cookie + ResponseEntity response = authorizationServerInfo + .postForStatus("/authorization/form", headers, form); + assertNotNull(response); + cookie = response.getHeaders().getFirst("Set-Cookie"); + headers = new HttpHeaders(); + headers.set("Cookie", cookie); + headers.setAccept(Collections.singletonList(MediaType.ALL)); + + // 4. 请求到 确认授权页面 ,获取确认授权页面的 _csrf 的 value + ResponseEntity confirm = authorizationServerInfo + .getForString("/oauth/authorize?response_type=code&client_id=oauth2&redirect_uri=http://example.com&scope=READ", headers); + + headers = confirm.getHeaders(); + // 确认过一次后,后面都会自动确认了,这里判断下是不是重定向请求 + // 如果不是,就表示是第一次,需要确认授权 + if (!confirm.getStatusCode().is3xxRedirection()) { + assertNotNull(confirm.getBody()); + Matcher matcherConfirm = Pattern.compile("(?s).*name=\"_csrf\".*?value=\"([^\"]+).*").matcher(confirm.getBody()); + assertTrue(matcherConfirm.find()); + headers = new HttpHeaders(); + headers.set("Cookie", cookie); + headers.setAccept(Collections.singletonList(MediaType.ALL)); + + // 5. 构建 同意授权 的表单 + form = new LinkedMultiValueMap<>(); + form.add("user_oauth_approval", "true"); + form.add("scope.READ", "true"); + form.add("_csrf", matcherConfirm.group(1)); + + // 6. 请求授权,获取 授权码 + headers = authorizationServerInfo.postForHeaders("/oauth/authorize", form, headers); + } + + URI location = headers.getLocation(); + assertNotNull(location); + String query = location.getQuery(); + assertNotNull(query); + String[] result = query.split("="); + assertEquals(2, result.length); + System.out.println(result[1]); + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/AuthorizationServerInfo.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/AuthorizationServerInfo.java new file mode 100644 index 0000000..0c22919 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/AuthorizationServerInfo.java @@ -0,0 +1,94 @@ +package com.xkcoding.oauth.oauth; + +import org.springframework.http.*; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RequestCallback; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.net.HttpURLConnection; + +/** + * 授权服务器工具类. + * + * @author EchoCow + * @date 2020/1/6 下午8:44 + */ +@SuppressWarnings("all") +public class AuthorizationServerInfo { + public static final String HOST = "http://127.0.0.1:8080"; + + private RestTemplate client; + + public AuthorizationServerInfo() { + client = new RestTemplate(); + client.setRequestFactory(new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException { + super.prepareConnection(connection, httpMethod); + connection.setInstanceFollowRedirects(false); + } + }); + client.setErrorHandler(new ResponseErrorHandler() { + public boolean hasError(ClientHttpResponse response) { + return false; + } + + public void handleError(ClientHttpResponse response) { + } + }); + } + + public ResponseEntity getForString(String path, final HttpHeaders headers) { + return client.exchange(getUrl(path), HttpMethod.GET, new HttpEntity<>(null, headers), String.class); + } + + public ResponseEntity getForString(String path) { + return getForString(path, new HttpHeaders()); + } + + public ResponseEntity postForStatus(String path, HttpHeaders headers, MultiValueMap formData) { + HttpHeaders actualHeaders = new HttpHeaders(); + actualHeaders.putAll(headers); + actualHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + return client.exchange(getUrl(path), HttpMethod.POST, + new HttpEntity<>(formData, actualHeaders), (Class) null); + } + + + public static String getUrl(String path) { + return HOST + path; + } + + public HttpHeaders postForHeaders(String path, MultiValueMap formData, final HttpHeaders headers) { + RequestCallback requestCallback = new NullRequestCallback(); + if (headers != null) { + requestCallback = request -> request.getHeaders().putAll(headers); + } + StringBuilder builder = new StringBuilder(getUrl(path)); + if (!path.contains("?")) { + builder.append("?"); + } else { + builder.append("&"); + } + for (String key : formData.keySet()) { + for (String value : formData.get(key)) { + builder.append(key).append("=").append(value); + builder.append("&"); + } + } + builder.deleteCharAt(builder.length() - 1); + + return client.execute(builder.toString(), HttpMethod.POST, requestCallback, + HttpMessage::getHeaders); + } + + private static final class NullRequestCallback implements RequestCallback { + public void doWithRequest(ClientHttpRequest request) { + } + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/ResourceOwnerPasswordGrantTests.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/ResourceOwnerPasswordGrantTests.java new file mode 100644 index 0000000..38d8d1d --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/oauth/ResourceOwnerPasswordGrantTests.java @@ -0,0 +1,39 @@ +package com.xkcoding.oauth.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 org.springframework.security.oauth2.common.OAuth2AccessToken; + +import java.util.Arrays; + +import static com.xkcoding.oauth.oauth.AuthorizationServerInfo.getUrl; +import static org.junit.jupiter.api.Assertions.*; + +/** + * . + * + * @author EchoCow + * @date 2020/1/6 下午9:14 + */ +public class ResourceOwnerPasswordGrantTests { + + @Test + void testConnectDirectlyToResourceServer() { + assertNotNull(accessToken()); + } + + public static String accessToken() { + ResourceOwnerPasswordResourceDetails resource = new ResourceOwnerPasswordResourceDetails(); + resource.setAccessTokenUri(getUrl("/oauth/token")); + resource.setClientId("oauth2"); + resource.setClientSecret("oauth2"); + resource.setId("oauth2"); + resource.setScope(Arrays.asList("READ", "WRITE")); + resource.setUsername("admin"); + resource.setPassword("123456"); + OAuth2RestTemplate template = new OAuth2RestTemplate(resource); + OAuth2AccessToken accessToken = template.getAccessToken(); + return accessToken.getValue(); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/repostiory/SysClientDetailsTest.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/repostiory/SysClientDetailsTest.java new file mode 100644 index 0000000..c0126bc --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/repostiory/SysClientDetailsTest.java @@ -0,0 +1,26 @@ +package com.xkcoding.oauth.repostiory; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +/** + * . + * + * @author EchoCow + * @date 2020/1/6 下午1:10 + */ +@DataJpaTest +public class SysClientDetailsTest { + @Autowired + private SysClientDetailsRepository sysClientDetailsRepository; + + @Test + public void autowiredSuccessWhenPassed() { + assertNotNull(sysClientDetailsRepository); + } + +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/repostiory/SysUserRepositoryTest.java b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/repostiory/SysUserRepositoryTest.java new file mode 100644 index 0000000..7df0679 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/java/com/xkcoding/oauth/repostiory/SysUserRepositoryTest.java @@ -0,0 +1,40 @@ +package com.xkcoding.oauth.repostiory; + +import com.xkcoding.oauth.entity.SysUser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + + +/** + * . + * + * @author EchoCow + * @date 2020/1/6 下午1:25 + */ +@DataJpaTest +public class SysUserRepositoryTest { + + @Autowired + private SysUserRepository sysUserRepository; + + @Test + public void autowiredSuccessWhenPassed() { + assertNotNull(sysUserRepository); + } + + @Test + @DisplayName("测试关联查询") + public void queryUserAndRoleWhenPassed() { + Optional admin = sysUserRepository.findFirstByUsername("admin"); + assertTrue(admin.isPresent()); + SysUser sysUser = admin.orElseGet(SysUser::new); + assertNotNull(sysUser.getRoles()); + assertEquals(1, sysUser.getRoles().size()); + } +} diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/application.yml b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/application.yml new file mode 100644 index 0000000..0324e25 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/application.yml @@ -0,0 +1,21 @@ +server: + port: 8080 + servlet: + context-path: /demo + +spring: + datasource: + url: jdbc:h2:mem:oauth2?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: root + password: 123456 + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + +logging: + level: + org.springframework.security: debug diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/import.sql b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/import.sql new file mode 100644 index 0000000..4dee7e7 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/import.sql @@ -0,0 +1,10 @@ +-- 测试数据 +INSERT INTO sys_client_details (id, access_token_validity_seconds, authorizations, auto_approve_scopes, client_id, client_secret, grant_types, redirect_url, refresh_token_validity_seconds, resource_ids, scopes) VALUES (1, 6000, null, null, 'oauth2', '$2a$10$O8uM8kd5SbsuoITG3tBifOcarqqI8GP19vzbqDzVHP5ZV9yOfvpYS', 'authorization_code,password', 'http://example.com', 6000, 'oauth2', 'READ,WRITE'); +INSERT INTO sys_client_details (id, access_token_validity_seconds, authorizations, auto_approve_scopes, client_id, client_secret, grant_types, redirect_url, refresh_token_validity_seconds, resource_ids, scopes) VALUES (2, 6000, null, null, 'test', '$2a$10$O8uM8kd5SbsuoITG3tBifOcarqqI8GP19vzbqDzVHP5ZV9yOfvpYS', 'authorization_code,password', 'http://example.com', 6000, 'test', 'READ'); +INSERT INTO sys_client_details (id, access_token_validity_seconds, authorizations, auto_approve_scopes, client_id, client_secret, grant_types, redirect_url, refresh_token_validity_seconds, resource_ids, scopes) VALUES (3, 6000, null, null, 'test', '$2a$10$O8uM8kd5SbsuoITG3tBifOcarqqI8GP19vzbqDzVHP5ZV9yOfvpYS', 'authorization_code,password', 'http://example.com', 6000, 'error', 'READ'); +INSERT INTO sys_role (id, name, description) VALUES (1, 'ROLE_ADMIN', '管理员'); +INSERT INTO sys_role (id, name, description) VALUES (2, 'ROLE_TEST', '测试'); +INSERT INTO sys_user (id, username, password) VALUES (1, 'admin', '$2a$10$xLH.pDNz3d2frOBQ6Gc.wuHY4ghwlSyFDgy0Ta.psXmm1YJjNaV1G'); +INSERT INTO sys_user (id, username, password) VALUES (2, 'test', '$2a$10$xLH.pDNz3d2frOBQ6Gc.wuHY4ghwlSyFDgy0Ta.psXmm1YJjNaV1G'); +INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1); +INSERT INTO sys_user_role (user_id, role_id) VALUES (2, 2); diff --git a/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/schema.sql b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/schema.sql new file mode 100644 index 0000000..1bb2156 --- /dev/null +++ b/spring-boot-demo-oauth/spring-boot-demo-oauth-authorization-server/src/test/resources/schema.sql @@ -0,0 +1,40 @@ +create table sys_client_details +( + id bigint auto_increment primary key, + access_token_validity_seconds int null, + authorizations varchar(255) null, + auto_approve_scopes varchar(255) null, + client_id varchar(255) null, + client_secret varchar(255) null, + grant_types varchar(255) null, + redirect_url varchar(255) null, + refresh_token_validity_seconds int null, + resource_ids varchar(255) null, + scopes varchar(255) null +); + +create table sys_role +( + id bigint auto_increment primary key, + name varchar(55) not null, + description varchar(55) null +); + +create table sys_user +( + id bigint auto_increment primary key, + username varchar(55) not null, + password varchar(128) not null +); + +create table sys_user_role +( + id bigint auto_increment primary key, + user_id bigint not null, + role_id bigint not null, + constraint sys_user_role_sys_role_id_fk + foreign key (role_id) references sys_role (id), + constraint sys_user_role_sys_user_id_fk + foreign key (user_id) references sys_user (id) +); + diff --git a/spring-boot-demo-oauth/src/main/resources/application.yml b/spring-boot-demo-oauth/src/main/resources/application.yml deleted file mode 100644 index a02fbde..0000000 --- a/spring-boot-demo-oauth/src/main/resources/application.yml +++ /dev/null @@ -1,4 +0,0 @@ -server: - port: 8080 - servlet: - context-path: /demo \ No newline at end of file diff --git a/spring-boot-demo-oauth/src/test/java/com/xkcoding/oauth/SpringBootDemoOauthApplicationTests.java b/spring-boot-demo-oauth/src/test/java/com/xkcoding/oauth/SpringBootDemoOauthApplicationTests.java deleted file mode 100644 index 9b53df2..0000000 --- a/spring-boot-demo-oauth/src/test/java/com/xkcoding/oauth/SpringBootDemoOauthApplicationTests.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.xkcoding.oauth; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringRunner; - -@RunWith(SpringRunner.class) -@SpringBootTest -public class SpringBootDemoOauthApplicationTests { - - @Test - public void contextLoads() { - } - -} - From a989d01e45887c5debf1f797bc6fed56fed939e6 Mon Sep 17 00:00:00 2001 From: EchoCow Date: Thu, 9 Jan 2020 17:07:14 +0800 Subject: [PATCH 2/2] :sparkles: Add spring-boot-demo-oauth-resource-server. --- spring-boot-demo-oauth/pom.xml | 26 +---- .../pom.xml | 29 +++++ .../src/main/resources/application.yml | 2 +- .../src/main/resources/templates/login.html | 2 +- .../README.adoc | 59 ++++++++++ .../pom.xml | 31 ++++++ .../SpringBootDemoResourceApplication.java | 21 ++++ .../config/OauthResourceServerConfig.java | 43 ++++++++ .../config/OauthResourceTokenConfig.java | 102 ++++++++++++++++++ .../oauth/controller/TestController.java | 60 +++++++++++ .../src/main/resources/application.yml | 30 ++++++ .../com/xkcoding/oauth/AuthorizationTest.java | 38 +++++++ .../oauth/controller/TestControllerTest.java | 83 ++++++++++++++ 13 files changed, 499 insertions(+), 27 deletions(-) create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/README.adoc create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/pom.xml create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/SpringBootDemoResourceApplication.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/config/OauthResourceServerConfig.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/config/OauthResourceTokenConfig.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/java/com/xkcoding/oauth/controller/TestController.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/main/resources/application.yml create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/test/java/com/xkcoding/oauth/AuthorizationTest.java create mode 100644 spring-boot-demo-oauth/spring-boot-demo-oauth-resource-server/src/test/java/com/xkcoding/oauth/controller/TestControllerTest.java diff --git a/spring-boot-demo-oauth/pom.xml b/spring-boot-demo-oauth/pom.xml index 724e86a..a403df2 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,31 +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 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()); + } +}