| @@ -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() { | |||||
| } | |||||
| } | |||||