@@ -86,6 +86,20 @@ | |||||
<user.agent.version>1.20</user.agent.version> | <user.agent.version>1.20</user.agent.version> | ||||
</properties> | </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> | <dependencyManagement> | ||||
<dependencies> | <dependencies> | ||||
<dependency> | <dependency> | ||||
@@ -5,7 +5,10 @@ | |||||
<artifactId>spring-boot-demo-oauth</artifactId> | <artifactId>spring-boot-demo-oauth</artifactId> | ||||
<version>1.0.0-SNAPSHOT</version> | <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> | <name>spring-boot-demo-oauth</name> | ||||
<description>Demo project for Spring Boot</description> | <description>Demo project for Spring Boot</description> | ||||
@@ -28,10 +31,49 @@ | |||||
<artifactId>spring-boot-starter-web</artifactId> | <artifactId>spring-boot-starter-web</artifactId> | ||||
</dependency> | </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> | <dependency> | ||||
<groupId>org.springframework.boot</groupId> | <groupId>org.springframework.boot</groupId> | ||||
<artifactId>spring-boot-starter-test</artifactId> | <artifactId>spring-boot-starter-test</artifactId> | ||||
<scope>test</scope> | <scope>test</scope> | ||||
<exclusions> | |||||
<exclusion> | |||||
<groupId>junit</groupId> | |||||
<artifactId>junit</artifactId> | |||||
</exclusion> | |||||
</exclusions> | |||||
</dependency> | </dependency> | ||||
<dependency> | <dependency> | ||||
@@ -44,6 +86,14 @@ | |||||
<artifactId>lombok</artifactId> | <artifactId>lombok</artifactId> | ||||
<optional>true</optional> | <optional>true</optional> | ||||
</dependency> | </dependency> | ||||
<dependency> | |||||
<groupId>org.junit.jupiter</groupId> | |||||
<artifactId>junit-jupiter</artifactId> | |||||
<version>5.5.2</version> | |||||
<scope>test</scope> | |||||
</dependency> | |||||
</dependencies> | </dependencies> | ||||
<build> | <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.SpringApplication; | ||||
import org.springframework.boot.autoconfigure.SpringBootApplication; | import org.springframework.boot.autoconfigure.SpringBootApplication; | ||||
import org.springframework.web.bind.annotation.GetMapping; | |||||
/** | /** | ||||
* <p> | * <p> | ||||
@@ -16,6 +15,8 @@ import org.springframework.web.bind.annotation.GetMapping; | |||||
* @copyright: Copyright (c) 2019 | * @copyright: Copyright (c) 2019 | ||||
* @version: V1.0 | * @version: V1.0 | ||||
* @modified: yangkai.shen | * @modified: yangkai.shen | ||||
* @modified: EchoCow | |||||
* @date: Modified in 2020-01-6 21:12 | |||||
*/ | */ | ||||
@SpringBootApplication | @SpringBootApplication | ||||
public class SpringBootDemoOauthApplication { | 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() { | |||||
} | |||||
} | |||||