@@ -86,6 +86,20 @@ | |||
<user.agent.version>1.20</user.agent.version> | |||
</properties> | |||
<repositories> | |||
<repository> | |||
<id>aliyun</id> | |||
<name>aliyun</name> | |||
<url>https://maven.aliyun.com/repository/public</url> | |||
<releases> | |||
<enabled>true</enabled> | |||
</releases> | |||
<snapshots> | |||
<enabled>false</enabled> | |||
</snapshots> | |||
</repository> | |||
</repositories> | |||
<dependencyManagement> | |||
<dependencies> | |||
<dependency> | |||
@@ -5,7 +5,10 @@ | |||
<artifactId>spring-boot-demo-oauth</artifactId> | |||
<version>1.0.0-SNAPSHOT</version> | |||
<packaging>jar</packaging> | |||
<modules> | |||
<module>spring-boot-demo-oauth-authorization-server</module> | |||
</modules> | |||
<packaging>pom</packaging> | |||
<name>spring-boot-demo-oauth</name> | |||
<description>Demo project for Spring Boot</description> | |||
@@ -28,10 +31,49 @@ | |||
<artifactId>spring-boot-starter-web</artifactId> | |||
</dependency> | |||
<dependency> | |||
<groupId>org.springframework.boot</groupId> | |||
<artifactId>spring-boot-starter-security</artifactId> | |||
</dependency> | |||
<dependency> | |||
<groupId>org.springframework.boot</groupId> | |||
<artifactId>spring-boot-starter-thymeleaf</artifactId> | |||
</dependency> | |||
<dependency> | |||
<groupId>org.springframework.boot</groupId> | |||
<artifactId>spring-boot-starter-data-jpa</artifactId> | |||
</dependency> | |||
<dependency> | |||
<groupId>org.springframework.security.oauth.boot</groupId> | |||
<artifactId>spring-security-oauth2-autoconfigure</artifactId> | |||
<version>${spring.boot.version}</version> | |||
</dependency> | |||
<dependency> | |||
<groupId>mysql</groupId> | |||
<artifactId>mysql-connector-java</artifactId> | |||
<scope>runtime</scope> | |||
</dependency> | |||
<dependency> | |||
<groupId>com.h2database</groupId> | |||
<artifactId>h2</artifactId> | |||
<scope>test</scope> | |||
</dependency> | |||
<dependency> | |||
<groupId>org.springframework.boot</groupId> | |||
<artifactId>spring-boot-starter-test</artifactId> | |||
<scope>test</scope> | |||
<exclusions> | |||
<exclusion> | |||
<groupId>junit</groupId> | |||
<artifactId>junit</artifactId> | |||
</exclusion> | |||
</exclusions> | |||
</dependency> | |||
<dependency> | |||
@@ -44,6 +86,14 @@ | |||
<artifactId>lombok</artifactId> | |||
<optional>true</optional> | |||
</dependency> | |||
<dependency> | |||
<groupId>org.junit.jupiter</groupId> | |||
<artifactId>junit-jupiter</artifactId> | |||
<version>5.5.2</version> | |||
<scope>test</scope> | |||
</dependency> | |||
</dependencies> | |||
<build> | |||
@@ -0,0 +1,273 @@ | |||
= spring-boot-demo-oauth-authorization-server | |||
Doc Writer <lzy@echocow.cn> | |||
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 从零到一完整实践(三)授权服务器 ] |
@@ -0,0 +1,15 @@ | |||
<?xml version="1.0" encoding="UTF-8"?> | |||
<project xmlns="http://maven.apache.org/POM/4.0.0" | |||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | |||
<parent> | |||
<artifactId>spring-boot-demo-oauth</artifactId> | |||
<groupId>com.xkcoding</groupId> | |||
<version>1.0.0-SNAPSHOT</version> | |||
</parent> | |||
<modelVersion>4.0.0</modelVersion> | |||
<artifactId>spring-boot-demo-oauth-authorization-server</artifactId> | |||
</project> |
@@ -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; | |||
/** | |||
* <p> | |||
@@ -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 { |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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")); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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")); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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()"); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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(); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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(); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @date 2020/1/7 上午9:16 | |||
*/ | |||
package com.xkcoding.oauth.config; |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<String, Object> 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; | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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; | |||
} | |||
} |
@@ -0,0 +1,14 @@ | |||
/** | |||
* 控制器。除了业务逻辑的以外,提供两个控制器来帮助完成自定义: | |||
* {@link com.xkcoding.oauth.controller.AuthorizationController} | |||
* 自定义的授权控制器,重新设置到我们的界面中去,不使用他的默认实现 | |||
* | |||
* {@link com.xkcoding.oauth.controller.Oauth2Controller} | |||
* 页面跳转的控制器,这里拿出来是因为真的可以做很多事。比如登录的时候携带点什么 | |||
* 或者退出的时候携带什么标识,都可以。 | |||
* | |||
* @author <a href="https://echocow.cn">EchoCow</a> | |||
* @date 2020/1/7 上午11:25 | |||
* @see org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint | |||
*/ | |||
package com.xkcoding.oauth.controller; |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<String> getScope() { | |||
return stringToSet(scopes); | |||
} | |||
/** | |||
* 授权类型 | |||
* | |||
* @return 结果 | |||
*/ | |||
@Override | |||
public Set<String> getAuthorizedGrantTypes() { | |||
return stringToSet(grantTypes); | |||
} | |||
@Override | |||
public Set<String> getResourceIds() { | |||
return stringToSet(resourceIds); | |||
} | |||
/** | |||
* 获取回调地址 | |||
* | |||
* @return redirectUrl | |||
*/ | |||
@Override | |||
public Set<String> getRegisteredRedirectUri() { | |||
return stringToSet(redirectUrl); | |||
} | |||
/** | |||
* 这里需要提一下 | |||
* 个人觉得这里应该是客户端所有的权限 | |||
* 但是已经有 scope 的存在可以很好的对客户端的权限进行认证了 | |||
* 那么在 oauth2 的四个角色中,这里就有可能是资源服务器的权限 | |||
* 但是一般资源服务器都有自己的权限管理机制,比如拿到用户信息后做 RBAC | |||
* 所以在 spring security 的默认实现中直接给的是空的一个集合 | |||
* 这里我们也给他一个空的把 | |||
* | |||
* @return GrantedAuthority | |||
*/ | |||
@Override | |||
public Collection<GrantedAuthority> getAuthorities() { | |||
return Collections.emptyList(); | |||
} | |||
/** | |||
* 判断是否自动授权 | |||
* | |||
* @param scope scope | |||
* @return 结果 | |||
*/ | |||
@Override | |||
public boolean isAutoApprove(String scope) { | |||
if (autoApproveScopes == null || autoApproveScopes.isEmpty()) { | |||
return false; | |||
} | |||
Set<String> 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<String, Object> getAdditionalInformation() { | |||
return Collections.emptyMap(); | |||
} | |||
private Set<String> stringToSet(String s) { | |||
return Arrays.stream(s.split(",")).collect(Collectors.toSet()); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<SysUser> users; | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<SysRole> roles; | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @date 2020/1/6 下午1:09 | |||
*/ | |||
public interface SysClientDetailsRepository extends JpaRepository<SysClientDetails, Long> { | |||
/** | |||
* 通过 clientId 查找客户端信息. | |||
* | |||
* @param clientId clientId | |||
* @return 结果 | |||
*/ | |||
Optional<SysClientDetails> findFirstByClientId(String clientId); | |||
/** | |||
* 根据客户端 id 删除客户端 | |||
* | |||
* @param clientId 客户端id | |||
*/ | |||
@Modifying | |||
void deleteByClientId(String clientId); | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @date 2020/1/6 下午1:08 | |||
*/ | |||
public interface SysUserRepository extends JpaRepository<SysUser, Long> { | |||
/** | |||
* 通过用户名查找用户. | |||
* | |||
* @param username 用户名 | |||
* @return 结果 | |||
*/ | |||
Optional<SysUser> findFirstByUsername(String username); | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<SysClientDetails> findAll(); | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @date 2020/1/6 下午3:44 | |||
*/ | |||
public interface SysUserService extends UserDetailsService { | |||
/** | |||
* 查询所有用户 | |||
* | |||
* @return 用户 | |||
*/ | |||
List<SysUser> 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); | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<SysClientDetails> findAll() { | |||
return sysClientDetailsRepository.findAll(); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<SimpleGrantedAuthority> 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<SysUser> 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); | |||
} | |||
} |
@@ -0,0 +1,7 @@ | |||
/** | |||
* service 层,继承并实现 spring 接口. | |||
* | |||
* @author <a href="https://echocow.cn">EchoCow</a> | |||
* @date 2020/1/7 上午9:16 | |||
*/ | |||
package com.xkcoding.oauth.service; |
@@ -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 |
@@ -0,0 +1,9 @@ | |||
-----BEGIN PUBLIC KEY----- | |||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkF9SyMHeGAsLMwbPsKj/ | |||
xpEtS0iCe8vTSBnIGBDZKmB3ma20Ry0Uzn3m+f40RwCXlxnUcvTw7ipoz0tMQERQ | |||
b3X4DkYCJXPK6pAD+R9/J5odEwrO2eysByWfcbMjsZw2u5pH5hleMS0YqkrGQOxJ | |||
pzlEcKxMePU5KYTbKUJkhOYPY+gQr61g6lF97WggSPtuQn1srT+Ptvfw6yRC4bdI | |||
0zV5emfXjmoLUwaQTRoGYhOFrm97vpoKiltSNIDFW01J1Lr+l77ddDFC6cdiAC0H | |||
5/eENWBBBTFWya8RlBTzHuikfFS1gP49PZ6MYJIVRs8p9YnnKTy7TVcGKY3XZMCA | |||
mwIDAQAB | |||
-----END PUBLIC KEY----- |
@@ -0,0 +1,55 @@ | |||
<!DOCTYPE html> | |||
<html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
<head th:replace="common/common::_header"> | |||
<title>确认您的授权信息</title> | |||
</head> | |||
<body> | |||
<div id="app"> | |||
<v-app> | |||
<v-content> | |||
<v-row class="fill-height align-sm-center" justify="center"> | |||
<v-col class="pa-0"> | |||
<v-card id="form-card" class="px-6 pb-7 px-sm-10 pb-sm-9 mx-auto" outlined> | |||
<v-form ref="auth" id="auth" th:action="@{/oauth/authorize}" method="post"> | |||
<v-spacer class="pt-6 pt-sm-12"></v-spacer> | |||
<v-card-title class="justify-center headline"> | |||
确认应用的授权信息 | |||
</v-card-title> | |||
<div class="text-center" style="height:44px"> | |||
<v-btn outlined rounded th:text="'客户端:' + ${#strings.toUpperCase(clientId)}"></v-btn> | |||
</div> | |||
<v-spacer></v-spacer> | |||
<v-list shaped> | |||
<v-subheader>当前应用将会获取您的以下权限:</v-subheader> | |||
<v-list-item-group color="primary"> | |||
<v-list-item th:each="scope : ${scopes}"> | |||
<v-list-item-content> | |||
<input type="hidden" th:name="'scope.' + ${scope}" value="true"> | |||
<v-list-item-title th:text="${#strings.toUpperCase(scope)}"></v-list-item-title> | |||
</v-list-item-content> | |||
</v-list-item> | |||
</v-list-item-group> | |||
</v-list> | |||
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/> | |||
<input type="hidden" name="user_oauth_approval" value="true"> | |||
<v-card-actions class="mt-6"> | |||
<v-spacer></v-spacer> | |||
<v-btn color="info" type="submit">确认授权</v-btn> | |||
</v-card-actions> | |||
</v-form> | |||
</v-card> | |||
</v-col> | |||
</v-row> | |||
</v-content> | |||
</v-app> | |||
</div> | |||
<div th:include="common/common::_footer"></div> | |||
<script> | |||
new Vue({ | |||
el: '#app', | |||
vuetify: new Vuetify(), | |||
}) | |||
</script> | |||
</body> | |||
</html> |
@@ -0,0 +1,33 @@ | |||
<!DOCTYPE html> | |||
<html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
<head th:fragment="_header"> | |||
<!--/*@thymesVar id="title" type="java.lang.String"*/--> | |||
<meta charset="UTF-8"> | |||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"> | |||
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet"> | |||
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet"> | |||
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet"> | |||
<style> | |||
#form-card { | |||
width: 30rem; | |||
} | |||
@media screen and (max-width: 450px) { | |||
#form-card{ | |||
width: 100%; | |||
height: 100%; | |||
padding: 2rem !important; | |||
} | |||
} | |||
.fill-height { | |||
height: 100%; | |||
} | |||
</style> | |||
</head> | |||
<body> | |||
<footer th:fragment="_footer"> | |||
<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script> | |||
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script> | |||
</footer> | |||
</body> | |||
</html> |
@@ -0,0 +1,45 @@ | |||
<!DOCTYPE html> | |||
<html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
<head th:replace="common/common::_header"> | |||
<title>发送了点小错误</title> | |||
</head> | |||
<body> | |||
<div id="app"> | |||
<v-app> | |||
<v-content> | |||
<v-responsive class="fill-height"> | |||
<v-container class="fill-height"> | |||
<v-layout align-center justify-center> | |||
<v-flex class="text-center"> | |||
<!--/*@thymesVar id="status" type="java.lang.String"*/--> | |||
<!--/*@thymesVar id="message" type="java.lang.String"*/--> | |||
<h1 class="display-1 blue--text" th:text="${status} + ' ' + ${message}">404 找不到页面</h1> | |||
<p class="mt-2">~~~</p> | |||
<v-btn outlined color="info" @click="handleBack">点击返回</v-btn> | |||
</v-flex> | |||
</v-layout> | |||
</v-container> | |||
</v-responsive> | |||
</v-content> | |||
</v-app> | |||
</div> | |||
<div th:include="common/common::_footer"></div> | |||
<script> | |||
new Vue({ | |||
el: '#app', | |||
vuetify: new Vuetify(), | |||
data: () => ({ | |||
text: '未知错误', | |||
code: 500 | |||
}), | |||
methods: { | |||
handleBack () { | |||
window.history.go(-1) | |||
} | |||
} | |||
}) | |||
</script> | |||
</body> | |||
</html> |
@@ -0,0 +1,110 @@ | |||
<!DOCTYPE html> | |||
<html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
<head th:replace="common/common::_header"> | |||
<title>欢迎登录</title> | |||
</head> | |||
<body> | |||
<div id="app"> | |||
<v-app> | |||
<v-content> | |||
<v-row class="fill-height align-sm-center" justify="center"> | |||
<v-col class="pa-0"> | |||
<v-card id="form-card" class="px-6 pb-7 px-sm-10 pb-sm-9 mx-auto" outlined> | |||
<v-form ref="login" id="login" th:action="@{/authorization/form}" method="post" @submit.native.prevent> | |||
<v-spacer class="pt-6 pt-sm-12"></v-spacer> | |||
<v-card-title class="justify-center headline"> | |||
欢迎登录 | |||
</v-card-title> | |||
<div class="login-user pb-2 text-center" style="height:44px"> | |||
<v-btn outlined rounded>{{nameText}}</v-btn> | |||
</div> | |||
<v-spacer></v-spacer> | |||
<v-card-subtitle class="text-center subtitle-1 pt-0"> | |||
<p th:if="${param.error == null}">{{infoText}}</p> | |||
<p th:unless="${param.error == null}" th:text="${param.error}" style="color: red;"></p> | |||
</v-card-subtitle> | |||
<v-spacer></v-spacer> | |||
<v-card-text> | |||
<v-window v-model="window" style="min-height:180px"> | |||
<v-window-item :key="0"> | |||
<v-text-field label="用户名/手机号/邮箱" name="username" type="text" clearable :rules="usernameRules" | |||
outlined v-model='user.username' @keyup.enter="next" ref="username" autofocus :counter="55"> | |||
</v-text-field> | |||
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/> | |||
</v-window-item> | |||
<v-window-item :key="1"> | |||
<v-text-field label="账户密码" name="password" type="password" required clearable | |||
outlined v-model='user.password' @keyup.enter="next" ref="password" autofocus> | |||
</v-text-field> | |||
</v-window-item> | |||
</v-window> | |||
</v-card-text> | |||
<v-card-actions> | |||
<v-btn outlined color="info" @click="previous">{{previousText}}</v-btn> | |||
<v-spacer></v-spacer> | |||
<v-btn color="info" type="button" @click="next" v-show="window === 0">下一步</v-btn> | |||
<v-btn color="info" type="submit" v-show="window === 1">登录</v-btn> | |||
</v-card-actions> | |||
</v-form> | |||
</v-card> | |||
</v-col> | |||
</v-row> | |||
</v-content> | |||
</v-app> | |||
</div> | |||
<div th:include="common/common::_footer"></div> | |||
<script > | |||
new Vue({ | |||
el: '#app', | |||
vuetify: new Vuetify(), | |||
data: function () { | |||
return { | |||
window: 0, | |||
previousText: '忘记密码', | |||
infoText: '使用您的帐号进行登录', | |||
nameText: 'DEMO', | |||
user: { | |||
username: null, | |||
password: null | |||
}, | |||
usernameRules:[ | |||
v => !!v || '请输入用户名/手机号/邮箱', | |||
v => !!v && v.length <= 55 || '长度不合法' | |||
], | |||
passwordRules:[ v => !!v || '请输入密码' ] | |||
} | |||
}, | |||
watch: { | |||
window: function (val) { | |||
if (val === 0) { | |||
this.infoText = '使用您的帐号进行登录' | |||
this.previousText = '忘记密码' | |||
this.nameText = 'DEMO' | |||
} else if (val === 1) { | |||
this.infoText = '要继续操作,请首先验证登录者是您本人' | |||
this.previousText = '上一步' | |||
this.nameText = this.user.username | |||
} | |||
} | |||
}, | |||
created () { | |||
this.window = 0 | |||
}, | |||
methods: { | |||
previous() { | |||
if (this.window === 0) { | |||
} else { | |||
this.window -= 1 | |||
} | |||
}, | |||
next () { | |||
if (this.window === 0) this.$refs.username.validate(true) && (this.window += 1) | |||
else this.$refs.password.validate(true) && document.getElementById("login").submit() | |||
} | |||
} | |||
}) | |||
</script> | |||
</body> | |||
</html> |
@@ -0,0 +1,44 @@ | |||
<!DOCTYPE html> | |||
<html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
<head th:replace="common/common::_header"> | |||
<title>确认退出吗?</title> | |||
</head> | |||
<body> | |||
<div id="app"> | |||
<v-app> | |||
<v-content> | |||
<v-row class="fill-height align-sm-center" justify="center"> | |||
<v-col class="pa-0"> | |||
<v-card id="form-card" class="px-6 pb-7 px-sm-10 pb-sm-9 mx-auto" outlined> | |||
<v-form ref="auth" id="auth" th:action="@{/oauth/logout}" method="post"> | |||
<v-spacer class="pt-6 pt-sm-12"></v-spacer> | |||
<v-card-title class="justify-center headline"> | |||
确认退出当前应用吗? | |||
</v-card-title> | |||
<div class="text-center" style="height:44px"> | |||
<v-btn outlined rounded th:text="'用户:' + ${#strings.toUpperCase(user)}"></v-btn> | |||
</div> | |||
<v-spacer></v-spacer> | |||
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/> | |||
<input type="hidden" name="redirectUrl" th:value="${redirectUrl}"/> | |||
<v-card-actions class="mt-6"> | |||
<v-spacer></v-spacer> | |||
<v-btn color="info" type="submit">确认退出</v-btn> | |||
</v-card-actions> | |||
</v-form> | |||
</v-card> | |||
</v-col> | |||
</v-row> | |||
</v-content> | |||
</v-app> | |||
</div> | |||
<div th:include="common/common::_footer"></div> | |||
<script> | |||
new Vue({ | |||
el: '#app', | |||
vuetify: new Vuetify(), | |||
}) | |||
</script> | |||
</body> | |||
</html> |
@@ -0,0 +1,155 @@ | |||
<!DOCTYPE html> | |||
<html lang="en" xmlns:th="http://www.thymeleaf.org"> | |||
<head> | |||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> | |||
<title></title> | |||
<meta charset="utf-8"/> | |||
<style type="text/css"> | |||
.lesson body { | |||
margin: 0; | |||
padding: 0; | |||
background: #fff; | |||
font-family: Verdana, Arial, Helvetica, sans-serif, serif; | |||
font-size: 14px; | |||
line-height: 24px; | |||
} | |||
.lesson div, .lesson p, .lesson span, .lesson img { | |||
margin: 0; | |||
padding: 0; | |||
} | |||
.lesson img { | |||
border: none; | |||
} | |||
.lesson .container { | |||
margin: 0 auto; | |||
} | |||
.lesson .title { | |||
margin: 0 auto; | |||
background: #efefef repeat-x; | |||
height: 30px; | |||
text-align: center; | |||
font-weight: bold; | |||
padding-top: 12px; | |||
font-size: 16px; | |||
color: #2d2d2d; | |||
} | |||
.lesson .content { | |||
margin: 4px; | |||
} | |||
.lesson .headline { | |||
padding: 6px; | |||
color: #2d2d2d; | |||
} | |||
.lesson .top, .lesson .bottom { | |||
display: block; | |||
font-size: 1px; | |||
} | |||
.lesson .xb1, .lesson .xb2, .lesson .xb3, .lesson .xb4 { | |||
display: block; | |||
overflow: hidden; | |||
} | |||
.lesson .xb1, .lesson .xb2, .lesson .xb3 { | |||
height: 1px; | |||
} | |||
.lesson .xb2, .lesson .xb3, .lesson .xb4 { | |||
border-left: 1px solid #BCBCBC; | |||
border-right: 1px solid #BCBCBC; | |||
} | |||
.lesson .xb1 { | |||
margin: 0 5px; | |||
background: #BCBCBC; | |||
} | |||
.lesson .xb2 { | |||
margin: 0 3px; | |||
border-width: 0 2px; | |||
} | |||
.lesson .xb3 { | |||
margin: 0 2px; | |||
} | |||
.lesson .xb4 { | |||
height: 2px; | |||
margin: 0 1px; | |||
} | |||
.lesson .lesson-content { | |||
display: block; | |||
} | |||
.lesson .line { | |||
margin-top: 6px; | |||
border-top: 1px dashed #B9B9B9; | |||
padding: 4px; | |||
} | |||
.lesson .content { | |||
padding: 6px; | |||
color: #666666; | |||
} | |||
.lesson .foot { | |||
padding: 6px; | |||
color: #777; | |||
} | |||
.lesson .font-darkblue { | |||
color: #006699; | |||
font-weight: bold; | |||
} | |||
.lesson .font-lightblue { | |||
color: #008BD1; | |||
font-weight: bold; | |||
} | |||
.lesson .font-gray { | |||
color: #888; | |||
font-size: 12px; | |||
} | |||
</style> | |||
</head> | |||
<body> | |||
<div class="lesson"> | |||
<div class="container"> | |||
<div class="title">云课程考试平台</div> | |||
<div class="content"> | |||
<p class="headline"><b>亲爱的用户,你好!</b></p> | |||
<b class="top"><b class="xb1"></b><b class="xb2"></b><b class="xb3"></b><b class="xb4"></b></b> | |||
<div class="lesson-content"> | |||
<div class="content"> | |||
<p><!--/*@thymesVar id="type" type="java.lang.String"*/--> | |||
<b th:text="'欢迎您' + ${type}">欢迎您注册</b><span id="userName" class="font-darkblue"> 云课程考试平台</span> | |||
</p> | |||
<p><b><!--/*@thymesVar id="type" type="java.lang.String"*/--> | |||
<b th:text="${type}">你的邮件</b>的验证码:</b><span class="font-lightblue"><span style="border-bottom: 1px dashed rgb(204, 204, 204); z-index: 1; position: static;"> <!--/*@thymesVar id="code" type="java.lang.String"*/--> | |||
<b th:text="${code}">验证码</b></span></span><br><span | |||
class="font-gray">(请输入该验证码完成 <span></span> 验证,验证码 | |||
<!--/*@thymesVar id="time" type="java.lang.Integer"*/--> | |||
<b th:text="${time}">10</b> 分钟内有效!)</span></p> | |||
<div class="line">如果您未申请云课程学习平台 <!--/*@thymesVar id="type" type="java.lang.String"*/--> | |||
<span th:text="${type}">$(type)</span> 服务,请忽略该邮件。 | |||
</div> | |||
</div> | |||
</div> | |||
<b class="bottom"><b class="xb4"></b><b class="xb3"></b><b class="xb2"></b><b class="xb1"></b></b> | |||
<p class="foot">如果仍有问题,请联系我们的管理员: <span | |||
style="border-bottom: 1px dashed rgb(204, 204, 204); z-index: 1; position: static;">000-00000000 | |||
</span></p> | |||
</div> | |||
</div> | |||
</div> | |||
</body> | |||
</html> |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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")); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<String> 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<String, String> form = new LinkedMultiValueMap<>(); | |||
form.add("username", "admin"); | |||
form.add("password", "123456"); | |||
form.add("_csrf", matcher.group(1)); | |||
// 3. 登录授权并获取登录成功的 cookie | |||
ResponseEntity<Void> 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<String> 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]); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<String> getForString(String path, final HttpHeaders headers) { | |||
return client.exchange(getUrl(path), HttpMethod.GET, new HttpEntity<>(null, headers), String.class); | |||
} | |||
public ResponseEntity<String> getForString(String path) { | |||
return getForString(path, new HttpHeaders()); | |||
} | |||
public ResponseEntity<Void> postForStatus(String path, HttpHeaders headers, MultiValueMap<String, String> 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<Void>) null); | |||
} | |||
public static String getUrl(String path) { | |||
return HOST + path; | |||
} | |||
public HttpHeaders postForHeaders(String path, MultiValueMap<String, String> 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) { | |||
} | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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(); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @date 2020/1/6 下午1:10 | |||
*/ | |||
@DataJpaTest | |||
public class SysClientDetailsTest { | |||
@Autowired | |||
private SysClientDetailsRepository sysClientDetailsRepository; | |||
@Test | |||
public void autowiredSuccessWhenPassed() { | |||
assertNotNull(sysClientDetailsRepository); | |||
} | |||
} |
@@ -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 <a href="https://echocow.cn">EchoCow</a> | |||
* @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<SysUser> admin = sysUserRepository.findFirstByUsername("admin"); | |||
assertTrue(admin.isPresent()); | |||
SysUser sysUser = admin.orElseGet(SysUser::new); | |||
assertNotNull(sysUser.getRoles()); | |||
assertEquals(1, sysUser.getRoles().size()); | |||
} | |||
} |
@@ -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 |
@@ -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); |
@@ -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) | |||
); | |||
@@ -1,4 +0,0 @@ | |||
server: | |||
port: 8080 | |||
servlet: | |||
context-path: /demo |
@@ -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() { | |||
} | |||
} | |||