| @@ -86,6 +86,20 @@ | |||
| <user.agent.version>1.20</user.agent.version> | |||
| </properties> | |||
| <repositories> | |||
| <repository> | |||
| <id>aliyun</id> | |||
| <name>aliyun</name> | |||
| <url>https://maven.aliyun.com/repository/public</url> | |||
| <releases> | |||
| <enabled>true</enabled> | |||
| </releases> | |||
| <snapshots> | |||
| <enabled>false</enabled> | |||
| </snapshots> | |||
| </repository> | |||
| </repositories> | |||
| <dependencyManagement> | |||
| <dependencies> | |||
| <dependency> | |||
| @@ -5,7 +5,10 @@ | |||
| <artifactId>spring-boot-demo-oauth</artifactId> | |||
| <version>1.0.0-SNAPSHOT</version> | |||
| <packaging>jar</packaging> | |||
| <modules> | |||
| <module>spring-boot-demo-oauth-authorization-server</module> | |||
| </modules> | |||
| <packaging>pom</packaging> | |||
| <name>spring-boot-demo-oauth</name> | |||
| <description>Demo project for Spring Boot</description> | |||
| @@ -28,10 +31,49 @@ | |||
| <artifactId>spring-boot-starter-web</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-security</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-thymeleaf</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-data-jpa</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.security.oauth.boot</groupId> | |||
| <artifactId>spring-security-oauth2-autoconfigure</artifactId> | |||
| <version>${spring.boot.version}</version> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>mysql</groupId> | |||
| <artifactId>mysql-connector-java</artifactId> | |||
| <scope>runtime</scope> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>com.h2database</groupId> | |||
| <artifactId>h2</artifactId> | |||
| <scope>test</scope> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-test</artifactId> | |||
| <scope>test</scope> | |||
| <exclusions> | |||
| <exclusion> | |||
| <groupId>junit</groupId> | |||
| <artifactId>junit</artifactId> | |||
| </exclusion> | |||
| </exclusions> | |||
| </dependency> | |||
| <dependency> | |||
| @@ -44,6 +86,14 @@ | |||
| <artifactId>lombok</artifactId> | |||
| <optional>true</optional> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.junit.jupiter</groupId> | |||
| <artifactId>junit-jupiter</artifactId> | |||
| <version>5.5.2</version> | |||
| <scope>test</scope> | |||
| </dependency> | |||
| </dependencies> | |||
| <build> | |||
| @@ -0,0 +1,273 @@ | |||
| = spring-boot-demo-oauth-authorization-server | |||
| Doc Writer <lzy@echocow.cn> | |||
| v1.0, 2019-01-07 | |||
| :toc: | |||
| spring boot oauth2 授权服务器, | |||
| - 授权码模式、密码模式、刷新令牌 | |||
| - 自定义 UserDetailService | |||
| - 自定义 ClientDetailService | |||
| - jwt 非对称加密 | |||
| - 自定义登录授权页面 | |||
| > SQL 语句 | |||
| > | |||
| > - DDL: `src/test/resources/schema.sql` | |||
| > - DML: `src/test/resources/import.sql` | |||
| 测试用例使用 h2 数据库,测试数据如下: | |||
| .测试客户端 | |||
| |=== | |||
| |客户端 id |客户端密钥 |资源服务器名称 |授权类型 | scopes| 回调地址 | |||
| |oauth2 | |||
| |oauth2 | |||
| |oauth2 | |||
| |authorization_code,password,refresh_token | |||
| |READ,WRITE | |||
| |http://example.com | |||
| |test | |||
| |oauth2 | |||
| |oauth2 | |||
| |authorization_code,password,refresh_token | |||
| |READ | |||
| |http://example.com | |||
| |error | |||
| |oauth2 | |||
| |test | |||
| |authorization_code,password,refresh_token | |||
| |READ | |||
| |http://example.com | |||
| |=== | |||
| .测试用户 | |||
| |=== | |||
| |用户名 |密码 |角色 | |||
| |admin | |||
| |123456 | |||
| |ROLE_ADMIN | |||
| |test | |||
| |123456 | |||
| |ROLE_TEST | |||
| |=== | |||
| == 授权码模式 | |||
| > 测试用例:`com.xkcoding.oauth.oauth.AuthorizationCodeGrantTests` | |||
| === 获取授权码 | |||
| - 请求地址: http://localhost:8080/oauth/authorize?response_type=code&client_id=oauth2&redirect_uri=http://example.com&scope=READ | |||
| - 用户名:admin | |||
| - 密码:123456 | |||
| image::image/Login.png[login] | |||
| === 确认授权 | |||
| 登录成功以后,进入确认授权页面。已经确认过的用户,不会再次要求确认。 | |||
| image::image/Confirm.png[confirm] | |||
| 确认授权后,获取授权码 | |||
| image::image/Code.png[code] | |||
| === 请求 token | |||
| 使用以下代码可以直接请求 token | |||
| [shell] | |||
| ---- | |||
| curl --location --request POST 'http://127.0.0.1:8080/oauth/token' \ | |||
| --header 'Content-Type: application/x-www-form-urlencoded' \ | |||
| --header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \ | |||
| --data-urlencode 'grant_type=authorization_code' \ | |||
| --data-urlencode 'code=GgX6QD' \ | |||
| --data-urlencode 'redirect_uri=http://example.com' \ | |||
| --data-urlencode 'client_id=oauth2' \ | |||
| --data-urlencode 'scope=READ WRITE' | |||
| ---- | |||
| 得到 token | |||
| [token] | |||
| ---- | |||
| { | |||
| "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyIl0sInVzZXJfbmFtZSI6ImFkbWluIiwic2NvcGUiOlsiUkVBRCJdLCJleHAiOjE1NzgzODY4MTYsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iXSwianRpIjoiZjAyMDhiNTUtYTJjYS00NjI4LTg5YjEtNzI5MzY4MzAxOWNhIiwiY2xpZW50X2lkIjoib2F1dGgyIn0.RqJpsin6bMnwI57cGpODTplLeW_gtNWHo_l4SimyRLsnxpCWm5oY1EOb4qVHpXvCbhNsUj69D462P7le13OOmexysZIQhaoGZ_CbIlEp63XsCnr5nSKeX3dgQlyTUDjOUL0WUtY2lKqLCGMeX_rpVhfmSh3b7MC0Ntxq5ao-943QMXGRIeRvJgSkvfY2HBN6-zx1H6rE0wxnUfBC1M08kUkFYlSmsFchiz-E_oTzJvE2D8lA9g-eEFU6cZ_els4Q77Vvc_O6SXUZ7o65vFyLyUjLvh9QF1825SGIUUdXTUYSZjnSAXChhRIAT5pLRHK-gthIzpOaWrgj6ebUoG02Eg", | |||
| "token_type": "bearer", | |||
| "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyIl0sInVzZXJfbmFtZSI6ImFkbWluIiwic2NvcGUiOlsiUkVBRCJdLCJhdGkiOiJmMDIwOGI1NS1hMmNhLTQ2MjgtODliMS03MjkzNjgzMDE5Y2EiLCJleHAiOjE1NzgzODY4MTYsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iXSwianRpIjoiMGViNTU2MTQtYjgxYS00MTFmLTg1MTAtZThkMjZmODJmMjJhIiwiY2xpZW50X2lkIjoib2F1dGgyIn0.CBGcjirkf-3187SgbZr0ikauiCS8U9YLaoR4sNlRQjd-gaIeF5PChnIs_yAmG_VpqPFlPRdSl8DA05S2QnFpT3TkRjyP-LPDZgsVAPfczMAdVywU1zOKYZeq-gM6p9bmGEabbZoBlIxOImsjeyFSCui6UtRTZjNlj3AhGIzvs52T8bDqC796iHPDZvJ97MMgsEiRyu-mxDm1o1LMuBX9RHCx9rAkBVf52q36bqWMcYAlDOu1wYjpmhalSLZyWcmraQvClEitXGJI4eTFapTnuXQuWFIL-973V_5Shw98-bk65zZQOEheazHrUf-n4h-sYT4akehnYSVxX2UIg9XsCw", | |||
| "expires_in": 5999, | |||
| "scope": "READ", | |||
| "jti": "f0208b55-a2ca-4628-89b1-7293683019ca" | |||
| } | |||
| ---- | |||
| == 密码模式 | |||
| > 测试用例:`com.xkcoding.oauth.oauth.ResourceOwnerPasswordGrantTests` | |||
| `test` 用户进行授权 | |||
| [source] | |||
| ---- | |||
| curl --location --request POST 'http://127.0.0.1:8080/oauth/token' \ | |||
| --header 'Content-Type: application/x-www-form-urlencoded' \ | |||
| --header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \ | |||
| --data-urlencode 'password=123456' \ | |||
| --data-urlencode 'username=test' \ | |||
| --data-urlencode 'grant_type=password' \ | |||
| --data-urlencode 'scope=READ WRITE' | |||
| ---- | |||
| == 刷新令牌 | |||
| 携带 `refresh_token` 去请求 | |||
| [source] | |||
| ---- | |||
| curl --location --request POST 'http://127.0.0.1:8080/oauth/token' \ | |||
| --header 'Content-Type: application/x-www-form-urlencoded' \ | |||
| --header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \ | |||
| --data-urlencode 'grant_type=refresh_token' \ | |||
| --data-urlencode 'refresh_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyIl0sInVzZXJfbmFtZSI6ImFkbWluIiwic2NvcGUiOlsiUkVBRCJdLCJhdGkiOiJmMDIwOGI1NS1hMmNhLTQ2MjgtODliMS03MjkzNjgzMDE5Y2EiLCJleHAiOjE1NzgzODY4MTYsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iXSwianRpIjoiMGViNTU2MTQtYjgxYS00MTFmLTg1MTAtZThkMjZmODJmMjJhIiwiY2xpZW50X2lkIjoib2F1dGgyIn0.CBGcjirkf-3187SgbZr0ikauiCS8U9YLaoR4sNlRQjd-gaIeF5PChnIs_yAmG_VpqPFlPRdSl8DA05S2QnFpT3TkRjyP-LPDZgsVAPfczMAdVywU1zOKYZeq-gM6p9bmGEabbZoBlIxOImsjeyFSCui6UtRTZjNlj3AhGIzvs52T8bDqC796iHPDZvJ97MMgsEiRyu-mxDm1o1LMuBX9RHCx9rAkBVf52q36bqWMcYAlDOu1wYjpmhalSLZyWcmraQvClEitXGJI4eTFapTnuXQuWFIL-973V_5Shw98-bk65zZQOEheazHrUf-n4h-sYT4akehnYSVxX2UIg9XsCw' | |||
| ---- | |||
| == 解析令牌 | |||
| 携带令牌解析 | |||
| [source] | |||
| ---- | |||
| curl --location --request POST 'http://127.0.0.1:8080/oauth/check_token' \ | |||
| --header 'Content-Type: application/x-www-form-urlencoded' \ | |||
| --header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' \ | |||
| --data-urlencode 'token=' | |||
| ---- | |||
| 解析结果 | |||
| [source] | |||
| ---- | |||
| { | |||
| "aud": [ | |||
| "oauth2" | |||
| ], | |||
| "user_name": "admin", | |||
| "scope": [ | |||
| "READ", | |||
| "WRITE" | |||
| ], | |||
| "active": true, | |||
| "exp": 1578389936, | |||
| "authorities": [ | |||
| "ROLE_ADMIN" | |||
| ], | |||
| "jti": "fe59fce9-6764-435e-8fa7-7320e11af811", | |||
| "client_id": "oauth2" | |||
| } | |||
| ---- | |||
| == 退出登录 | |||
| 授权码模式登陆是在授权服务器上登录的,所以退出也要在授权服务器上退出。 | |||
| 携带回调地址进行退出,退出完成后跳转到回调地址: | |||
| image::image/Logout.png[logout] | |||
| 退出以后自动跳转到回调地址(要加 `http` 或 `https`) | |||
| == 获取公钥 | |||
| 通过访问 '/oauth/token_key' 获取 JWT 公钥 | |||
| [source] | |||
| ---- | |||
| curl --location --request GET 'http://127.0.0.1:8080/oauth/token_key' \ | |||
| --header 'Content-Type: application/x-www-form-urlencoded' \ | |||
| --header 'Authorization: Basic b2F1dGgyOm9hdXRoMg==' | |||
| ---- | |||
| 获取后 | |||
| [source] | |||
| ---- | |||
| { | |||
| "alg": "SHA256withRSA", | |||
| "value": "-----BEGIN PUBLIC KEY-----\n......\n-----END PUBLIC KEY-----" | |||
| } | |||
| ---- | |||
| == 核心配置 | |||
| === 授权服务器配置 | |||
| [Oauth2AuthorizationServerConfig] | |||
| ---- | |||
| @Override | |||
| public void configure(AuthorizationServerEndpointsConfigurer endpoints) { | |||
| endpoints.authenticationManager(authenticationManager) | |||
| // 自定义用户 | |||
| .userDetailsService(sysUserService) | |||
| // 内存存储 | |||
| .tokenStore(tokenStore) | |||
| // jwt 令牌转换 | |||
| .accessTokenConverter(jwtAccessTokenConverter); | |||
| } | |||
| @Override | |||
| public void configure(ClientDetailsServiceConfigurer clients) throws Exception { | |||
| // 从数据库读取我们自定义的客户端信息 | |||
| clients.withClientDetails(sysClientDetailsService); | |||
| } | |||
| @Override | |||
| public void configure(AuthorizationServerSecurityConfigurer security) { | |||
| security | |||
| // 获取 token key 需要进行 basic 认证客户端信息 | |||
| .tokenKeyAccess("isAuthenticated()") | |||
| // 获取 token 信息同样需要 basic 认证客户端信息 | |||
| .checkTokenAccess("isAuthenticated()"); | |||
| } | |||
| ---- | |||
| === 安全配置 | |||
| [WebSecurityConfig] | |||
| ---- | |||
| @Override | |||
| protected void configure(HttpSecurity http) throws Exception { | |||
| http | |||
| // 开启表单登录,授权码模式的时候进行登录 | |||
| .formLogin() | |||
| // 路径等 | |||
| .loginPage("/oauth/login") | |||
| .loginProcessingUrl("/authorization/form") | |||
| // 失败以后携带错误信息进行再次跳转登录页面 | |||
| .failureHandler(clientLoginFailureHandler) | |||
| .and() | |||
| // 退出登录相关 | |||
| .logout() | |||
| .logoutUrl("/oauth/logout") | |||
| .logoutSuccessHandler(clientLogoutSuccessHandler) | |||
| .and() | |||
| // 授权服务器安全配置 | |||
| .authorizeRequests() | |||
| .antMatchers("/oauth/**").permitAll() | |||
| .anyRequest() | |||
| .authenticated(); | |||
| } | |||
| ---- | |||
| == 参考 | |||
| - https://echocow.cn/articles/2019/07/14/1563096109754.html[Spring Security Oauth2 从零到一完整实践(三)授权服务器 ] | |||
| @@ -0,0 +1,15 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project xmlns="http://maven.apache.org/POM/4.0.0" | |||
| xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |||
| xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | |||
| <parent> | |||
| <artifactId>spring-boot-demo-oauth</artifactId> | |||
| <groupId>com.xkcoding</groupId> | |||
| <version>1.0.0-SNAPSHOT</version> | |||
| </parent> | |||
| <modelVersion>4.0.0</modelVersion> | |||
| <artifactId>spring-boot-demo-oauth-authorization-server</artifactId> | |||
| </project> | |||
| @@ -2,7 +2,6 @@ package com.xkcoding.oauth; | |||
| import org.springframework.boot.SpringApplication; | |||
| import org.springframework.boot.autoconfigure.SpringBootApplication; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| /** | |||
| * <p> | |||
| @@ -16,6 +15,8 @@ import org.springframework.web.bind.annotation.GetMapping; | |||
| * @copyright: Copyright (c) 2019 | |||
| * @version: V1.0 | |||
| * @modified: yangkai.shen | |||
| * @modified: EchoCow | |||
| * @date: Modified in 2020-01-6 21:12 | |||
| */ | |||
| @SpringBootApplication | |||
| public class SpringBootDemoOauthApplication { | |||
| @@ -0,0 +1,31 @@ | |||
| package com.xkcoding.oauth.config; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.http.HttpStatus; | |||
| import org.springframework.security.core.AuthenticationException; | |||
| import org.springframework.security.web.authentication.AuthenticationFailureHandler; | |||
| import org.springframework.stereotype.Component; | |||
| import javax.servlet.http.HttpServletRequest; | |||
| import javax.servlet.http.HttpServletResponse; | |||
| import java.io.IOException; | |||
| import java.net.URLEncoder; | |||
| /** | |||
| * 登录失败处理器,失败后携带失败信息重定向到登录地址重新登录. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/7 下午1:01 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| public class ClientLoginFailureHandler implements AuthenticationFailureHandler { | |||
| @Override | |||
| public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, | |||
| AuthenticationException exception) throws IOException { | |||
| log.debug("Login failed!"); | |||
| response.setStatus(HttpStatus.UNAUTHORIZED.value()); | |||
| response.sendRedirect("/oauth/login?error=" | |||
| + URLEncoder.encode(exception.getLocalizedMessage(), "UTF-8")); | |||
| } | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| package com.xkcoding.oauth.config; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.http.HttpStatus; | |||
| import org.springframework.security.core.Authentication; | |||
| import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; | |||
| import org.springframework.stereotype.Component; | |||
| import javax.servlet.http.HttpServletRequest; | |||
| import javax.servlet.http.HttpServletResponse; | |||
| import java.io.IOException; | |||
| /** | |||
| * 客户团退出登录成功处理器. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午22:11 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| public class ClientLogoutSuccessHandler implements LogoutSuccessHandler { | |||
| @Override | |||
| public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { | |||
| response.setStatus(HttpStatus.FOUND.value()); | |||
| // 跳转到客户端的回调地址 | |||
| response.sendRedirect(request.getParameter("redirectUrl")); | |||
| } | |||
| } | |||
| @@ -0,0 +1,54 @@ | |||
| package com.xkcoding.oauth.config; | |||
| import com.xkcoding.oauth.service.SysClientDetailsService; | |||
| import com.xkcoding.oauth.service.SysUserService; | |||
| import lombok.RequiredArgsConstructor; | |||
| import org.springframework.context.annotation.Configuration; | |||
| import org.springframework.security.authentication.AuthenticationManager; | |||
| import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; | |||
| import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; | |||
| import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; | |||
| import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; | |||
| import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer; | |||
| import org.springframework.security.oauth2.provider.token.TokenStore; | |||
| import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; | |||
| /** | |||
| * . | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午1:32 | |||
| */ | |||
| @Configuration | |||
| @RequiredArgsConstructor | |||
| @EnableAuthorizationServer | |||
| public class Oauth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { | |||
| private final SysClientDetailsService sysClientDetailsService; | |||
| private final SysUserService sysUserService; | |||
| private final TokenStore tokenStore; | |||
| private final AuthenticationManager authenticationManager; | |||
| private final JwtAccessTokenConverter jwtAccessTokenConverter; | |||
| @Override | |||
| public void configure(AuthorizationServerEndpointsConfigurer endpoints) { | |||
| endpoints.authenticationManager(authenticationManager) | |||
| .userDetailsService(sysUserService) | |||
| .tokenStore(tokenStore) | |||
| .accessTokenConverter(jwtAccessTokenConverter); | |||
| } | |||
| @Override | |||
| public void configure(ClientDetailsServiceConfigurer clients) throws Exception { | |||
| // 从数据库读取我们自定义的客户端信息 | |||
| clients.withClientDetails(sysClientDetailsService); | |||
| } | |||
| @Override | |||
| public void configure(AuthorizationServerSecurityConfigurer security) { | |||
| security | |||
| // 获取 token key 需要进行 basic 认证客户端信息 | |||
| .tokenKeyAccess("isAuthenticated()") | |||
| // 获取 token 信息同样需要 basic 认证客户端信息 | |||
| .checkTokenAccess("isAuthenticated()"); | |||
| } | |||
| } | |||
| @@ -0,0 +1,74 @@ | |||
| package com.xkcoding.oauth.config; | |||
| import lombok.RequiredArgsConstructor; | |||
| import org.springframework.context.annotation.Bean; | |||
| import org.springframework.context.annotation.Configuration; | |||
| import org.springframework.context.annotation.Primary; | |||
| import org.springframework.core.io.ClassPathResource; | |||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |||
| import org.springframework.security.crypto.password.PasswordEncoder; | |||
| import org.springframework.security.oauth2.provider.token.TokenStore; | |||
| import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore; | |||
| import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; | |||
| import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory; | |||
| import java.security.KeyPair; | |||
| /** | |||
| * token 相关配置. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午1:33 | |||
| */ | |||
| @Configuration | |||
| @RequiredArgsConstructor | |||
| public class Oauth2AuthorizationTokenConfig { | |||
| /** | |||
| * 声明 内存 TokenStore 实现,用来存储 token 相关. | |||
| * 默认实现有 mysql、redis | |||
| * | |||
| * @return InMemoryTokenStore | |||
| */ | |||
| @Bean | |||
| @Primary | |||
| public TokenStore tokenStore() { | |||
| return new InMemoryTokenStore(); | |||
| } | |||
| /** | |||
| * jwt 令牌 配置,非对称加密 | |||
| * | |||
| * @return 转换器 | |||
| */ | |||
| @Bean | |||
| public JwtAccessTokenConverter jwtAccessTokenConverter() { | |||
| final JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter(); | |||
| accessTokenConverter.setKeyPair(keyPair()); | |||
| return accessTokenConverter; | |||
| } | |||
| /** | |||
| * 密钥 keyPair. | |||
| * 可用于生成 jwt / jwk. | |||
| * | |||
| * @return keyPair | |||
| */ | |||
| @Bean | |||
| public KeyPair keyPair() { | |||
| KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("oauth2.jks"), "123456".toCharArray()); | |||
| return keyStoreKeyFactory.getKeyPair("oauth2"); | |||
| } | |||
| /** | |||
| * 加密方式,使用 BCrypt. | |||
| * 参数越大加密次数越多,时间越久. | |||
| * 默认为 10. | |||
| * | |||
| * @return PasswordEncoder | |||
| */ | |||
| @Bean | |||
| public PasswordEncoder passwordEncoder() { | |||
| return new BCryptPasswordEncoder(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,54 @@ | |||
| package com.xkcoding.oauth.config; | |||
| import lombok.RequiredArgsConstructor; | |||
| import org.springframework.context.annotation.Bean; | |||
| import org.springframework.security.authentication.AuthenticationManager; | |||
| import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; | |||
| import org.springframework.security.config.annotation.web.builders.HttpSecurity; | |||
| import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; | |||
| import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; | |||
| /** | |||
| * 安全配置. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午1:27 | |||
| */ | |||
| @EnableWebSecurity | |||
| @RequiredArgsConstructor | |||
| @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) | |||
| public class WebSecurityConfig extends WebSecurityConfigurerAdapter { | |||
| private final ClientLogoutSuccessHandler clientLogoutSuccessHandler; | |||
| private final ClientLoginFailureHandler clientLoginFailureHandler; | |||
| @Override | |||
| protected void configure(HttpSecurity http) throws Exception { | |||
| http | |||
| .formLogin() | |||
| .loginPage("/oauth/login") | |||
| .failureHandler(clientLoginFailureHandler) | |||
| .loginProcessingUrl("/authorization/form") | |||
| .and() | |||
| .logout() | |||
| .logoutUrl("/oauth/logout") | |||
| .logoutSuccessHandler(clientLogoutSuccessHandler) | |||
| .and() | |||
| .authorizeRequests() | |||
| .antMatchers("/oauth/**").permitAll() | |||
| .anyRequest() | |||
| .authenticated(); | |||
| } | |||
| /** | |||
| * 授权管理. | |||
| * | |||
| * @return 认证管理对象 | |||
| * @throws Exception 认证异常信息 | |||
| */ | |||
| @Override | |||
| @Bean | |||
| public AuthenticationManager authenticationManagerBean() throws Exception { | |||
| return super.authenticationManagerBean(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,22 @@ | |||
| /** | |||
| * spring security oauth2 的相关配置。 | |||
| * 使用 spring boot oauth2 自动配置。 | |||
| * {@link com.xkcoding.oauth.config.Oauth2AuthorizationServerConfig} | |||
| * 授权服务器相关的配置,主要设置授权服务器如何读取客户端、用户信息和一些端点配置 | |||
| * 可以在这里配置更多的东西,例如端点映射,token 增强等 | |||
| * | |||
| * {@link com.xkcoding.oauth.config.Oauth2AuthorizationTokenConfig} | |||
| * 授权服务器 token 相关的配置,主要设置 jwt、加密方式等信息 | |||
| * | |||
| * {@link com.xkcoding.oauth.config.ClientLogoutSuccessHandler} | |||
| * 资源服务器退出以后的处理。在授权码模式中,所有的客户端都需要跳转到授权服务器进行登录 | |||
| * 当登录成功以后跳转到回调地址,如果用户需要登出,也要跳转到授权服务器这里进行登出 | |||
| * 但是 spring security oauth2 似乎并没有这个逻辑。 | |||
| * 所以自己给登出端点加了一个 redirect_url 参数,表示登出成功以后要跳转的地址 | |||
| * 这个处理器就是来完成登出成功以后的跳转操作的。 | |||
| * | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/7 上午9:16 | |||
| */ | |||
| package com.xkcoding.oauth.config; | |||
| @@ -0,0 +1,43 @@ | |||
| package com.xkcoding.oauth.controller; | |||
| import org.springframework.security.oauth2.provider.AuthorizationRequest; | |||
| import org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint; | |||
| import org.springframework.stereotype.Controller; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.SessionAttributes; | |||
| import org.springframework.web.servlet.ModelAndView; | |||
| import java.util.Map; | |||
| /** | |||
| * 自定义确认授权页面. | |||
| * 需要注意的是: 不能在代码中 setComplete,因为整个授权流程并没有结束 | |||
| * 我们只是在中途修改了它确认的一些信息而已。 | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午4:42 | |||
| */ | |||
| @Controller | |||
| @SessionAttributes("authorizationRequest") | |||
| public class AuthorizationController { | |||
| /** | |||
| * 自定义确认授权页面 | |||
| * 当然你也可以使用 {@link AuthorizationEndpoint#setUserApprovalPage(String)} 方法 | |||
| * 进行设置,但是 model 就没有那么灵活了 | |||
| * | |||
| * @param model model | |||
| * @return ModelAndView | |||
| */ | |||
| @GetMapping("/oauth/confirm_access") | |||
| public ModelAndView getAccessConfirmation(Map<String, Object> model) { | |||
| AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest"); | |||
| ModelAndView view = new ModelAndView(); | |||
| view.setViewName("authorization"); | |||
| view.addObject("clientId", authorizationRequest.getClientId()); | |||
| // 传递 scope 过去,Set 集合 | |||
| view.addObject("scopes", authorizationRequest.getScope()); | |||
| return view; | |||
| } | |||
| } | |||
| @@ -0,0 +1,55 @@ | |||
| package com.xkcoding.oauth.controller; | |||
| import lombok.RequiredArgsConstructor; | |||
| import org.springframework.stereotype.Controller; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.client.ResourceAccessException; | |||
| import org.springframework.web.servlet.ModelAndView; | |||
| import java.security.Principal; | |||
| import java.util.Objects; | |||
| /** | |||
| * 页面控制器. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午4:30 | |||
| */ | |||
| @Controller | |||
| @RequestMapping("/oauth") | |||
| @RequiredArgsConstructor | |||
| public class Oauth2Controller { | |||
| /** | |||
| * 授权码模式跳转到登录页面 | |||
| * | |||
| * @return view | |||
| */ | |||
| @GetMapping("/login") | |||
| public String loginView() { | |||
| return "login"; | |||
| } | |||
| /** | |||
| * 退出登录 | |||
| * | |||
| * @param redirectUrl 退出完成后的回调地址 | |||
| * @param principal 用户信息 | |||
| * @return 结果 | |||
| */ | |||
| @GetMapping("/logout") | |||
| public ModelAndView logoutView( | |||
| @RequestParam("redirect_url") String redirectUrl, Principal principal) { | |||
| if (Objects.isNull(principal)) { | |||
| throw new ResourceAccessException("请求错误,用户尚未登录"); | |||
| } | |||
| ModelAndView view = new ModelAndView(); | |||
| view.setViewName("logout"); | |||
| view.addObject("user", principal.getName()); | |||
| view.addObject("redirectUrl", redirectUrl); | |||
| return view; | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| /** | |||
| * 控制器。除了业务逻辑的以外,提供两个控制器来帮助完成自定义: | |||
| * {@link com.xkcoding.oauth.controller.AuthorizationController} | |||
| * 自定义的授权控制器,重新设置到我们的界面中去,不使用他的默认实现 | |||
| * | |||
| * {@link com.xkcoding.oauth.controller.Oauth2Controller} | |||
| * 页面跳转的控制器,这里拿出来是因为真的可以做很多事。比如登录的时候携带点什么 | |||
| * 或者退出的时候携带什么标识,都可以。 | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/7 上午11:25 | |||
| * @see org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint | |||
| */ | |||
| package com.xkcoding.oauth.controller; | |||
| @@ -0,0 +1,191 @@ | |||
| package com.xkcoding.oauth.entity; | |||
| import lombok.Data; | |||
| import org.springframework.security.core.GrantedAuthority; | |||
| import org.springframework.security.oauth2.provider.ClientDetails; | |||
| import org.springframework.security.oauth2.provider.client.BaseClientDetails; | |||
| import javax.persistence.*; | |||
| import java.util.*; | |||
| import java.util.stream.Collectors; | |||
| /** | |||
| * 客户端信息. | |||
| * 这里实现了 ClientDetails 接口 | |||
| * 个人建议不应该在实体类里面写任何逻辑代码 | |||
| * 而为了避免实体类耦合严重不应该去实现这个接口的 | |||
| * 但是这里为了演示和 {@link SysUser} 不同的方式,所以就选择实现这个接口了 | |||
| * 另一种方式是写一个方法将它转化为默认实现 {@link BaseClientDetails} 比较好一点并且简单很多 | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午12:54 | |||
| */ | |||
| @Data | |||
| @Table | |||
| @Entity | |||
| public class SysClientDetails implements ClientDetails { | |||
| /** | |||
| * 主键 | |||
| */ | |||
| @Id | |||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | |||
| private Long id; | |||
| /** | |||
| * client id | |||
| */ | |||
| private String clientId; | |||
| /** | |||
| * client 密钥 | |||
| */ | |||
| private String clientSecret; | |||
| /** | |||
| * 资源服务器名称 | |||
| */ | |||
| private String resourceIds; | |||
| /** | |||
| * 授权域 | |||
| */ | |||
| private String scopes; | |||
| /** | |||
| * 授权类型 | |||
| */ | |||
| private String grantTypes; | |||
| /** | |||
| * 重定向地址,授权码时必填 | |||
| */ | |||
| private String redirectUrl; | |||
| /** | |||
| * 授权信息 | |||
| */ | |||
| private String authorizations; | |||
| /** | |||
| * 授权令牌有效时间 | |||
| */ | |||
| private Integer accessTokenValiditySeconds; | |||
| /** | |||
| * 刷新令牌有效时间 | |||
| */ | |||
| private Integer refreshTokenValiditySeconds; | |||
| /** | |||
| * 自动授权请求域 | |||
| */ | |||
| private String autoApproveScopes; | |||
| /** | |||
| * 是否安全 | |||
| * | |||
| * @return 结果 | |||
| */ | |||
| @Override | |||
| public boolean isSecretRequired() { | |||
| return this.clientSecret != null; | |||
| } | |||
| /** | |||
| * 是否有 scopes | |||
| * | |||
| * @return 结果 | |||
| */ | |||
| @Override | |||
| public boolean isScoped() { | |||
| return this.scopes != null && !this.scopes.isEmpty(); | |||
| } | |||
| /** | |||
| * scopes | |||
| * | |||
| * @return scopes | |||
| */ | |||
| @Override | |||
| public Set<String> getScope() { | |||
| return stringToSet(scopes); | |||
| } | |||
| /** | |||
| * 授权类型 | |||
| * | |||
| * @return 结果 | |||
| */ | |||
| @Override | |||
| public Set<String> getAuthorizedGrantTypes() { | |||
| return stringToSet(grantTypes); | |||
| } | |||
| @Override | |||
| public Set<String> getResourceIds() { | |||
| return stringToSet(resourceIds); | |||
| } | |||
| /** | |||
| * 获取回调地址 | |||
| * | |||
| * @return redirectUrl | |||
| */ | |||
| @Override | |||
| public Set<String> getRegisteredRedirectUri() { | |||
| return stringToSet(redirectUrl); | |||
| } | |||
| /** | |||
| * 这里需要提一下 | |||
| * 个人觉得这里应该是客户端所有的权限 | |||
| * 但是已经有 scope 的存在可以很好的对客户端的权限进行认证了 | |||
| * 那么在 oauth2 的四个角色中,这里就有可能是资源服务器的权限 | |||
| * 但是一般资源服务器都有自己的权限管理机制,比如拿到用户信息后做 RBAC | |||
| * 所以在 spring security 的默认实现中直接给的是空的一个集合 | |||
| * 这里我们也给他一个空的把 | |||
| * | |||
| * @return GrantedAuthority | |||
| */ | |||
| @Override | |||
| public Collection<GrantedAuthority> getAuthorities() { | |||
| return Collections.emptyList(); | |||
| } | |||
| /** | |||
| * 判断是否自动授权 | |||
| * | |||
| * @param scope scope | |||
| * @return 结果 | |||
| */ | |||
| @Override | |||
| public boolean isAutoApprove(String scope) { | |||
| if (autoApproveScopes == null || autoApproveScopes.isEmpty()) { | |||
| return false; | |||
| } | |||
| Set<String> authorizationSet = stringToSet(authorizations); | |||
| for (String auto : authorizationSet) { | |||
| if ("true".equalsIgnoreCase(auto) || scope.matches(auto)) { | |||
| return true; | |||
| } | |||
| } | |||
| return false; | |||
| } | |||
| /** | |||
| * additional information 是 spring security 的保留字段 | |||
| * 暂时用不到,直接给个空的即可 | |||
| * | |||
| * @return map | |||
| */ | |||
| @Override | |||
| public Map<String, Object> getAdditionalInformation() { | |||
| return Collections.emptyMap(); | |||
| } | |||
| private Set<String> stringToSet(String s) { | |||
| return Arrays.stream(s.split(",")).collect(Collectors.toSet()); | |||
| } | |||
| } | |||
| @@ -0,0 +1,49 @@ | |||
| package com.xkcoding.oauth.entity; | |||
| import lombok.Data; | |||
| import lombok.EqualsAndHashCode; | |||
| import lombok.ToString; | |||
| import org.codehaus.jackson.annotate.JsonIgnore; | |||
| import javax.persistence.*; | |||
| import java.util.Set; | |||
| /** | |||
| * 这里完全可以只用一个字段代替的 | |||
| * 但是想了想还是模拟实际的情况来把 | |||
| * 角色信息. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午12:44 | |||
| */ | |||
| @Data | |||
| @Table | |||
| @Entity | |||
| @EqualsAndHashCode(exclude = {"users"}) | |||
| @ToString(exclude = "users") | |||
| public class SysRole { | |||
| /** | |||
| * 主键. | |||
| */ | |||
| @Id | |||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | |||
| private Long id; | |||
| /** | |||
| * 角色名称,按照 spring security 规范 | |||
| * 需要以 ROLE_ 开头. | |||
| */ | |||
| private String name; | |||
| /** | |||
| * 角色描述. | |||
| */ | |||
| private String description; | |||
| /** | |||
| * 当前角色所有用户. | |||
| */ | |||
| @ManyToMany(mappedBy = "roles", fetch = FetchType.EAGER) | |||
| private Set<SysUser> users; | |||
| } | |||
| @@ -0,0 +1,55 @@ | |||
| package com.xkcoding.oauth.entity; | |||
| import lombok.Data; | |||
| import lombok.EqualsAndHashCode; | |||
| import lombok.ToString; | |||
| import org.springframework.security.core.userdetails.User; | |||
| import org.springframework.security.core.userdetails.UserDetails; | |||
| import javax.persistence.*; | |||
| import java.util.Set; | |||
| /** | |||
| * 用户实体. | |||
| * 避免实体类耦合,所以不去实现 {@link UserDetails} 接口 | |||
| * 因为有且只有登录加载用户的时候才会需要这个接口 | |||
| * 我们就手动构建一个 {@link User} 的默认实现就可以了 | |||
| * 实现接口的方式可以参考 {@link SysClientDetails} | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午12:41 | |||
| */ | |||
| @Data | |||
| @Table | |||
| @Entity | |||
| @EqualsAndHashCode(exclude = "roles") | |||
| @ToString(exclude = "roles") | |||
| public class SysUser { | |||
| /** | |||
| * 主键. | |||
| */ | |||
| @Id | |||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | |||
| private Long id; | |||
| /** | |||
| * 用户名. | |||
| */ | |||
| private String username; | |||
| /** | |||
| * 密码. | |||
| */ | |||
| private String password; | |||
| /** | |||
| * 当前用户所有角色. | |||
| */ | |||
| @ManyToMany(fetch = FetchType.EAGER) | |||
| @JoinTable(name = "sys_user_role", | |||
| joinColumns = @JoinColumn(name = "user_id"), | |||
| inverseJoinColumns = @JoinColumn(name = "role_id") | |||
| ) | |||
| private Set<SysRole> roles; | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| package com.xkcoding.oauth.repostiory; | |||
| import com.xkcoding.oauth.entity.SysClientDetails; | |||
| import org.springframework.data.jpa.repository.JpaRepository; | |||
| import org.springframework.data.jpa.repository.Modifying; | |||
| import java.util.Optional; | |||
| /** | |||
| * 客户端信息. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午1:09 | |||
| */ | |||
| public interface SysClientDetailsRepository extends JpaRepository<SysClientDetails, Long> { | |||
| /** | |||
| * 通过 clientId 查找客户端信息. | |||
| * | |||
| * @param clientId clientId | |||
| * @return 结果 | |||
| */ | |||
| Optional<SysClientDetails> findFirstByClientId(String clientId); | |||
| /** | |||
| * 根据客户端 id 删除客户端 | |||
| * | |||
| * @param clientId 客户端id | |||
| */ | |||
| @Modifying | |||
| void deleteByClientId(String clientId); | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| package com.xkcoding.oauth.repostiory; | |||
| import com.xkcoding.oauth.entity.SysUser; | |||
| import org.springframework.data.jpa.repository.JpaRepository; | |||
| import java.util.Optional; | |||
| /** | |||
| * 用户信息仓库. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午1:08 | |||
| */ | |||
| public interface SysUserRepository extends JpaRepository<SysUser, Long> { | |||
| /** | |||
| * 通过用户名查找用户. | |||
| * | |||
| * @param username 用户名 | |||
| * @return 结果 | |||
| */ | |||
| Optional<SysUser> findFirstByUsername(String username); | |||
| } | |||
| @@ -0,0 +1,67 @@ | |||
| package com.xkcoding.oauth.service; | |||
| import com.xkcoding.oauth.entity.SysClientDetails; | |||
| import org.springframework.security.oauth2.provider.ClientAlreadyExistsException; | |||
| import org.springframework.security.oauth2.provider.ClientDetailsService; | |||
| import org.springframework.security.oauth2.provider.ClientRegistrationService; | |||
| import org.springframework.security.oauth2.provider.NoSuchClientException; | |||
| import java.util.List; | |||
| /** | |||
| * 声明自己的实现. | |||
| * 参见 {@link ClientRegistrationService} | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午1:39 | |||
| */ | |||
| public interface SysClientDetailsService extends ClientDetailsService { | |||
| /** | |||
| * 通过客户端 id 查询 | |||
| * | |||
| * @param clientId 客户端 id | |||
| * @return 结果 | |||
| */ | |||
| SysClientDetails findByClientId(String clientId); | |||
| /** | |||
| * 添加客户端信息. | |||
| * | |||
| * @param clientDetails 客户端信息 | |||
| * @throws ClientAlreadyExistsException 客户端已存在 | |||
| */ | |||
| void addClientDetails(SysClientDetails clientDetails) throws ClientAlreadyExistsException; | |||
| /** | |||
| * 更新客户端信息,不包括 clientSecret. | |||
| * | |||
| * @param clientDetails 客户端信息 | |||
| * @throws NoSuchClientException 找不到客户端异常 | |||
| */ | |||
| void updateClientDetails(SysClientDetails clientDetails) throws NoSuchClientException; | |||
| /** | |||
| * 更新客户端密钥. | |||
| * | |||
| * @param clientId 客户端 id | |||
| * @param clientSecret 客户端密钥 | |||
| * @throws NoSuchClientException 找不到客户端异常 | |||
| */ | |||
| void updateClientSecret(String clientId, String clientSecret) throws NoSuchClientException; | |||
| /** | |||
| * 删除客户端信息. | |||
| * | |||
| * @param clientId 客户端 id | |||
| * @throws NoSuchClientException 找不到客户端异常 | |||
| */ | |||
| void removeClientDetails(String clientId) throws NoSuchClientException; | |||
| /** | |||
| * 查询所有 | |||
| * | |||
| * @return 结果 | |||
| */ | |||
| List<SysClientDetails> findAll(); | |||
| } | |||
| @@ -0,0 +1,59 @@ | |||
| package com.xkcoding.oauth.service; | |||
| import com.xkcoding.oauth.entity.SysUser; | |||
| import org.springframework.security.core.userdetails.UserDetailsService; | |||
| import java.util.List; | |||
| /** | |||
| * . | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午3:44 | |||
| */ | |||
| public interface SysUserService extends UserDetailsService { | |||
| /** | |||
| * 查询所有用户 | |||
| * | |||
| * @return 用户 | |||
| */ | |||
| List<SysUser> findAll(); | |||
| /** | |||
| * 通过 id 查询用户 | |||
| * | |||
| * @param id id | |||
| * @return 用户 | |||
| */ | |||
| SysUser findById(Long id); | |||
| /** | |||
| * 创建用户 | |||
| * | |||
| * @param sysUser 用户 | |||
| */ | |||
| void createUser(SysUser sysUser); | |||
| /** | |||
| * 更新用户 | |||
| * | |||
| * @param sysUser 用户 | |||
| */ | |||
| void updateUser(SysUser sysUser); | |||
| /** | |||
| * 更新用户 密码 | |||
| * | |||
| * @param id 用户 id | |||
| * @param password 用户密码 | |||
| */ | |||
| void updatePassword(Long id, String password); | |||
| /** | |||
| * 删除用户. | |||
| * | |||
| * @param id id | |||
| */ | |||
| void deleteUser(Long id); | |||
| } | |||
| @@ -0,0 +1,73 @@ | |||
| package com.xkcoding.oauth.service.impl; | |||
| import com.xkcoding.oauth.entity.SysClientDetails; | |||
| import com.xkcoding.oauth.repostiory.SysClientDetailsRepository; | |||
| import com.xkcoding.oauth.service.SysClientDetailsService; | |||
| import lombok.RequiredArgsConstructor; | |||
| import org.springframework.security.crypto.password.PasswordEncoder; | |||
| import org.springframework.security.oauth2.provider.*; | |||
| import org.springframework.stereotype.Service; | |||
| import java.util.List; | |||
| /** | |||
| * 客户端 相关操作. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午1:37 | |||
| */ | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class SysClientDetailsServiceImpl implements SysClientDetailsService { | |||
| private final SysClientDetailsRepository sysClientDetailsRepository; | |||
| private final PasswordEncoder passwordEncoder; | |||
| @Override | |||
| public ClientDetails loadClientByClientId(String id) throws ClientRegistrationException { | |||
| return sysClientDetailsRepository.findFirstByClientId(id) | |||
| .orElseThrow(() -> new ClientRegistrationException("Loading client exception.")); | |||
| } | |||
| @Override | |||
| public SysClientDetails findByClientId(String clientId) { | |||
| return sysClientDetailsRepository.findFirstByClientId(clientId) | |||
| .orElseThrow(() -> new ClientRegistrationException("Loading client exception.")); | |||
| } | |||
| @Override | |||
| public void addClientDetails(SysClientDetails clientDetails) throws ClientAlreadyExistsException { | |||
| clientDetails.setId(null); | |||
| if (sysClientDetailsRepository.findFirstByClientId(clientDetails.getClientId()).isPresent()) { | |||
| throw new ClientAlreadyExistsException(String.format("Client id %s already exist.", clientDetails.getClientId())); | |||
| } | |||
| sysClientDetailsRepository.save(clientDetails); | |||
| } | |||
| @Override | |||
| public void updateClientDetails(SysClientDetails clientDetails) throws NoSuchClientException { | |||
| SysClientDetails exist = sysClientDetailsRepository.findFirstByClientId(clientDetails.getClientId()) | |||
| .orElseThrow(() -> new NoSuchClientException("No such client!")); | |||
| clientDetails.setClientSecret(exist.getClientSecret()); | |||
| sysClientDetailsRepository.save(clientDetails); | |||
| } | |||
| @Override | |||
| public void updateClientSecret(String clientId, String clientSecret) throws NoSuchClientException { | |||
| SysClientDetails exist = sysClientDetailsRepository.findFirstByClientId(clientId) | |||
| .orElseThrow(() -> new NoSuchClientException("No such client!")); | |||
| exist.setClientSecret(passwordEncoder.encode(clientSecret)); | |||
| sysClientDetailsRepository.save(exist); | |||
| } | |||
| @Override | |||
| public void removeClientDetails(String clientId) throws NoSuchClientException { | |||
| sysClientDetailsRepository.deleteByClientId(clientId); | |||
| } | |||
| @Override | |||
| public List<SysClientDetails> findAll() { | |||
| return sysClientDetailsRepository.findAll(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,76 @@ | |||
| package com.xkcoding.oauth.service.impl; | |||
| import com.xkcoding.oauth.entity.SysUser; | |||
| import com.xkcoding.oauth.repostiory.SysUserRepository; | |||
| import com.xkcoding.oauth.service.SysUserService; | |||
| import lombok.RequiredArgsConstructor; | |||
| import org.springframework.security.core.authority.SimpleGrantedAuthority; | |||
| import org.springframework.security.core.userdetails.User; | |||
| import org.springframework.security.core.userdetails.UserDetails; | |||
| import org.springframework.security.core.userdetails.UsernameNotFoundException; | |||
| import org.springframework.security.crypto.password.PasswordEncoder; | |||
| import org.springframework.stereotype.Service; | |||
| import java.util.List; | |||
| import java.util.stream.Collectors; | |||
| /** | |||
| * 用户相关操作. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午3:06 | |||
| */ | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class SysUserServiceImpl implements SysUserService { | |||
| private final SysUserRepository sysUserRepository; | |||
| private final PasswordEncoder passwordEncoder; | |||
| @Override | |||
| public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { | |||
| SysUser sysUser = sysUserRepository.findFirstByUsername(username) | |||
| .orElseThrow(() -> new UsernameNotFoundException("User not found!")); | |||
| List<SimpleGrantedAuthority> roles = sysUser.getRoles().stream() | |||
| .map(sysRole -> new SimpleGrantedAuthority(sysRole.getName())) | |||
| .collect(Collectors.toList()); | |||
| // 在这里手动构建 UserDetails 的默认实现 | |||
| return new User(sysUser.getUsername(), sysUser.getPassword(), roles); | |||
| } | |||
| @Override | |||
| public List<SysUser> findAll() { | |||
| return sysUserRepository.findAll(); | |||
| } | |||
| @Override | |||
| public SysUser findById(Long id) { | |||
| return sysUserRepository.findById(id) | |||
| .orElseThrow(() -> new RuntimeException("找不到用户")); | |||
| } | |||
| @Override | |||
| public void createUser(SysUser sysUser) { | |||
| sysUser.setId(null); | |||
| sysUserRepository.save(sysUser); | |||
| } | |||
| @Override | |||
| public void updateUser(SysUser sysUser) { | |||
| sysUser.setPassword(null); | |||
| sysUserRepository.save(sysUser); | |||
| } | |||
| @Override | |||
| public void updatePassword(Long id, String password) { | |||
| SysUser exist = findById(id); | |||
| exist.setPassword(passwordEncoder.encode(password)); | |||
| sysUserRepository.save(exist); | |||
| } | |||
| @Override | |||
| public void deleteUser(Long id) { | |||
| sysUserRepository.deleteById(id); | |||
| } | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| /** | |||
| * service 层,继承并实现 spring 接口. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/7 上午9:16 | |||
| */ | |||
| package com.xkcoding.oauth.service; | |||
| @@ -0,0 +1,22 @@ | |||
| server: | |||
| port: 8080 | |||
| spring: | |||
| datasource: | |||
| url: jdbc:mysql://localhost:3306/oauth | |||
| username: root | |||
| password: 123456 | |||
| hikari: | |||
| data-source-properties: | |||
| useSSL: false | |||
| serverTimezone: GMT+8 | |||
| useUnicode: true | |||
| characterEncoding: utf8 | |||
| jpa: | |||
| hibernate: | |||
| ddl-auto: update | |||
| show-sql: true | |||
| logging: | |||
| level: | |||
| org.springframework.security: debug | |||
| @@ -0,0 +1,9 @@ | |||
| -----BEGIN PUBLIC KEY----- | |||
| MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkF9SyMHeGAsLMwbPsKj/ | |||
| xpEtS0iCe8vTSBnIGBDZKmB3ma20Ry0Uzn3m+f40RwCXlxnUcvTw7ipoz0tMQERQ | |||
| b3X4DkYCJXPK6pAD+R9/J5odEwrO2eysByWfcbMjsZw2u5pH5hleMS0YqkrGQOxJ | |||
| pzlEcKxMePU5KYTbKUJkhOYPY+gQr61g6lF97WggSPtuQn1srT+Ptvfw6yRC4bdI | |||
| 0zV5emfXjmoLUwaQTRoGYhOFrm97vpoKiltSNIDFW01J1Lr+l77ddDFC6cdiAC0H | |||
| 5/eENWBBBTFWya8RlBTzHuikfFS1gP49PZ6MYJIVRs8p9YnnKTy7TVcGKY3XZMCA | |||
| mwIDAQAB | |||
| -----END PUBLIC KEY----- | |||
| @@ -0,0 +1,55 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
| <head th:replace="common/common::_header"> | |||
| <title>确认您的授权信息</title> | |||
| </head> | |||
| <body> | |||
| <div id="app"> | |||
| <v-app> | |||
| <v-content> | |||
| <v-row class="fill-height align-sm-center" justify="center"> | |||
| <v-col class="pa-0"> | |||
| <v-card id="form-card" class="px-6 pb-7 px-sm-10 pb-sm-9 mx-auto" outlined> | |||
| <v-form ref="auth" id="auth" th:action="@{/oauth/authorize}" method="post"> | |||
| <v-spacer class="pt-6 pt-sm-12"></v-spacer> | |||
| <v-card-title class="justify-center headline"> | |||
| 确认应用的授权信息 | |||
| </v-card-title> | |||
| <div class="text-center" style="height:44px"> | |||
| <v-btn outlined rounded th:text="'客户端:' + ${#strings.toUpperCase(clientId)}"></v-btn> | |||
| </div> | |||
| <v-spacer></v-spacer> | |||
| <v-list shaped> | |||
| <v-subheader>当前应用将会获取您的以下权限:</v-subheader> | |||
| <v-list-item-group color="primary"> | |||
| <v-list-item th:each="scope : ${scopes}"> | |||
| <v-list-item-content> | |||
| <input type="hidden" th:name="'scope.' + ${scope}" value="true"> | |||
| <v-list-item-title th:text="${#strings.toUpperCase(scope)}"></v-list-item-title> | |||
| </v-list-item-content> | |||
| </v-list-item> | |||
| </v-list-item-group> | |||
| </v-list> | |||
| <input type="hidden" name="_csrf" th:value="${_csrf.token}"/> | |||
| <input type="hidden" name="user_oauth_approval" value="true"> | |||
| <v-card-actions class="mt-6"> | |||
| <v-spacer></v-spacer> | |||
| <v-btn color="info" type="submit">确认授权</v-btn> | |||
| </v-card-actions> | |||
| </v-form> | |||
| </v-card> | |||
| </v-col> | |||
| </v-row> | |||
| </v-content> | |||
| </v-app> | |||
| </div> | |||
| <div th:include="common/common::_footer"></div> | |||
| <script> | |||
| new Vue({ | |||
| el: '#app', | |||
| vuetify: new Vuetify(), | |||
| }) | |||
| </script> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,33 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
| <head th:fragment="_header"> | |||
| <!--/*@thymesVar id="title" type="java.lang.String"*/--> | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"> | |||
| <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet"> | |||
| <link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet"> | |||
| <link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet"> | |||
| <style> | |||
| #form-card { | |||
| width: 30rem; | |||
| } | |||
| @media screen and (max-width: 450px) { | |||
| #form-card{ | |||
| width: 100%; | |||
| height: 100%; | |||
| padding: 2rem !important; | |||
| } | |||
| } | |||
| .fill-height { | |||
| height: 100%; | |||
| } | |||
| </style> | |||
| </head> | |||
| <body> | |||
| <footer th:fragment="_footer"> | |||
| <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script> | |||
| <script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script> | |||
| </footer> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,45 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
| <head th:replace="common/common::_header"> | |||
| <title>发送了点小错误</title> | |||
| </head> | |||
| <body> | |||
| <div id="app"> | |||
| <v-app> | |||
| <v-content> | |||
| <v-responsive class="fill-height"> | |||
| <v-container class="fill-height"> | |||
| <v-layout align-center justify-center> | |||
| <v-flex class="text-center"> | |||
| <!--/*@thymesVar id="status" type="java.lang.String"*/--> | |||
| <!--/*@thymesVar id="message" type="java.lang.String"*/--> | |||
| <h1 class="display-1 blue--text" th:text="${status} + ' ' + ${message}">404 找不到页面</h1> | |||
| <p class="mt-2">~~~</p> | |||
| <v-btn outlined color="info" @click="handleBack">点击返回</v-btn> | |||
| </v-flex> | |||
| </v-layout> | |||
| </v-container> | |||
| </v-responsive> | |||
| </v-content> | |||
| </v-app> | |||
| </div> | |||
| <div th:include="common/common::_footer"></div> | |||
| <script> | |||
| new Vue({ | |||
| el: '#app', | |||
| vuetify: new Vuetify(), | |||
| data: () => ({ | |||
| text: '未知错误', | |||
| code: 500 | |||
| }), | |||
| methods: { | |||
| handleBack () { | |||
| window.history.go(-1) | |||
| } | |||
| } | |||
| }) | |||
| </script> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,110 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
| <head th:replace="common/common::_header"> | |||
| <title>欢迎登录</title> | |||
| </head> | |||
| <body> | |||
| <div id="app"> | |||
| <v-app> | |||
| <v-content> | |||
| <v-row class="fill-height align-sm-center" justify="center"> | |||
| <v-col class="pa-0"> | |||
| <v-card id="form-card" class="px-6 pb-7 px-sm-10 pb-sm-9 mx-auto" outlined> | |||
| <v-form ref="login" id="login" th:action="@{/authorization/form}" method="post" @submit.native.prevent> | |||
| <v-spacer class="pt-6 pt-sm-12"></v-spacer> | |||
| <v-card-title class="justify-center headline"> | |||
| 欢迎登录 | |||
| </v-card-title> | |||
| <div class="login-user pb-2 text-center" style="height:44px"> | |||
| <v-btn outlined rounded>{{nameText}}</v-btn> | |||
| </div> | |||
| <v-spacer></v-spacer> | |||
| <v-card-subtitle class="text-center subtitle-1 pt-0"> | |||
| <p th:if="${param.error == null}">{{infoText}}</p> | |||
| <p th:unless="${param.error == null}" th:text="${param.error}" style="color: red;"></p> | |||
| </v-card-subtitle> | |||
| <v-spacer></v-spacer> | |||
| <v-card-text> | |||
| <v-window v-model="window" style="min-height:180px"> | |||
| <v-window-item :key="0"> | |||
| <v-text-field label="用户名/手机号/邮箱" name="username" type="text" clearable :rules="usernameRules" | |||
| outlined v-model='user.username' @keyup.enter="next" ref="username" autofocus :counter="55"> | |||
| </v-text-field> | |||
| <input type="hidden" name="_csrf" th:value="${_csrf.token}"/> | |||
| </v-window-item> | |||
| <v-window-item :key="1"> | |||
| <v-text-field label="账户密码" name="password" type="password" required clearable | |||
| outlined v-model='user.password' @keyup.enter="next" ref="password" autofocus> | |||
| </v-text-field> | |||
| </v-window-item> | |||
| </v-window> | |||
| </v-card-text> | |||
| <v-card-actions> | |||
| <v-btn outlined color="info" @click="previous">{{previousText}}</v-btn> | |||
| <v-spacer></v-spacer> | |||
| <v-btn color="info" type="button" @click="next" v-show="window === 0">下一步</v-btn> | |||
| <v-btn color="info" type="submit" v-show="window === 1">登录</v-btn> | |||
| </v-card-actions> | |||
| </v-form> | |||
| </v-card> | |||
| </v-col> | |||
| </v-row> | |||
| </v-content> | |||
| </v-app> | |||
| </div> | |||
| <div th:include="common/common::_footer"></div> | |||
| <script > | |||
| new Vue({ | |||
| el: '#app', | |||
| vuetify: new Vuetify(), | |||
| data: function () { | |||
| return { | |||
| window: 0, | |||
| previousText: '忘记密码', | |||
| infoText: '使用您的帐号进行登录', | |||
| nameText: 'DEMO', | |||
| user: { | |||
| username: null, | |||
| password: null | |||
| }, | |||
| usernameRules:[ | |||
| v => !!v || '请输入用户名/手机号/邮箱', | |||
| v => !!v && v.length <= 55 || '长度不合法' | |||
| ], | |||
| passwordRules:[ v => !!v || '请输入密码' ] | |||
| } | |||
| }, | |||
| watch: { | |||
| window: function (val) { | |||
| if (val === 0) { | |||
| this.infoText = '使用您的帐号进行登录' | |||
| this.previousText = '忘记密码' | |||
| this.nameText = 'DEMO' | |||
| } else if (val === 1) { | |||
| this.infoText = '要继续操作,请首先验证登录者是您本人' | |||
| this.previousText = '上一步' | |||
| this.nameText = this.user.username | |||
| } | |||
| } | |||
| }, | |||
| created () { | |||
| this.window = 0 | |||
| }, | |||
| methods: { | |||
| previous() { | |||
| if (this.window === 0) { | |||
| } else { | |||
| this.window -= 1 | |||
| } | |||
| }, | |||
| next () { | |||
| if (this.window === 0) this.$refs.username.validate(true) && (this.window += 1) | |||
| else this.$refs.password.validate(true) && document.getElementById("login").submit() | |||
| } | |||
| } | |||
| }) | |||
| </script> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,44 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="zh" xmlns:th="http://www.thymeleaf.org"> | |||
| <head th:replace="common/common::_header"> | |||
| <title>确认退出吗?</title> | |||
| </head> | |||
| <body> | |||
| <div id="app"> | |||
| <v-app> | |||
| <v-content> | |||
| <v-row class="fill-height align-sm-center" justify="center"> | |||
| <v-col class="pa-0"> | |||
| <v-card id="form-card" class="px-6 pb-7 px-sm-10 pb-sm-9 mx-auto" outlined> | |||
| <v-form ref="auth" id="auth" th:action="@{/oauth/logout}" method="post"> | |||
| <v-spacer class="pt-6 pt-sm-12"></v-spacer> | |||
| <v-card-title class="justify-center headline"> | |||
| 确认退出当前应用吗? | |||
| </v-card-title> | |||
| <div class="text-center" style="height:44px"> | |||
| <v-btn outlined rounded th:text="'用户:' + ${#strings.toUpperCase(user)}"></v-btn> | |||
| </div> | |||
| <v-spacer></v-spacer> | |||
| <input type="hidden" name="_csrf" th:value="${_csrf.token}"/> | |||
| <input type="hidden" name="redirectUrl" th:value="${redirectUrl}"/> | |||
| <v-card-actions class="mt-6"> | |||
| <v-spacer></v-spacer> | |||
| <v-btn color="info" type="submit">确认退出</v-btn> | |||
| </v-card-actions> | |||
| </v-form> | |||
| </v-card> | |||
| </v-col> | |||
| </v-row> | |||
| </v-content> | |||
| </v-app> | |||
| </div> | |||
| <div th:include="common/common::_footer"></div> | |||
| <script> | |||
| new Vue({ | |||
| el: '#app', | |||
| vuetify: new Vuetify(), | |||
| }) | |||
| </script> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,155 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en" xmlns:th="http://www.thymeleaf.org"> | |||
| <head> | |||
| <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> | |||
| <title></title> | |||
| <meta charset="utf-8"/> | |||
| <style type="text/css"> | |||
| .lesson body { | |||
| margin: 0; | |||
| padding: 0; | |||
| background: #fff; | |||
| font-family: Verdana, Arial, Helvetica, sans-serif, serif; | |||
| font-size: 14px; | |||
| line-height: 24px; | |||
| } | |||
| .lesson div, .lesson p, .lesson span, .lesson img { | |||
| margin: 0; | |||
| padding: 0; | |||
| } | |||
| .lesson img { | |||
| border: none; | |||
| } | |||
| .lesson .container { | |||
| margin: 0 auto; | |||
| } | |||
| .lesson .title { | |||
| margin: 0 auto; | |||
| background: #efefef repeat-x; | |||
| height: 30px; | |||
| text-align: center; | |||
| font-weight: bold; | |||
| padding-top: 12px; | |||
| font-size: 16px; | |||
| color: #2d2d2d; | |||
| } | |||
| .lesson .content { | |||
| margin: 4px; | |||
| } | |||
| .lesson .headline { | |||
| padding: 6px; | |||
| color: #2d2d2d; | |||
| } | |||
| .lesson .top, .lesson .bottom { | |||
| display: block; | |||
| font-size: 1px; | |||
| } | |||
| .lesson .xb1, .lesson .xb2, .lesson .xb3, .lesson .xb4 { | |||
| display: block; | |||
| overflow: hidden; | |||
| } | |||
| .lesson .xb1, .lesson .xb2, .lesson .xb3 { | |||
| height: 1px; | |||
| } | |||
| .lesson .xb2, .lesson .xb3, .lesson .xb4 { | |||
| border-left: 1px solid #BCBCBC; | |||
| border-right: 1px solid #BCBCBC; | |||
| } | |||
| .lesson .xb1 { | |||
| margin: 0 5px; | |||
| background: #BCBCBC; | |||
| } | |||
| .lesson .xb2 { | |||
| margin: 0 3px; | |||
| border-width: 0 2px; | |||
| } | |||
| .lesson .xb3 { | |||
| margin: 0 2px; | |||
| } | |||
| .lesson .xb4 { | |||
| height: 2px; | |||
| margin: 0 1px; | |||
| } | |||
| .lesson .lesson-content { | |||
| display: block; | |||
| } | |||
| .lesson .line { | |||
| margin-top: 6px; | |||
| border-top: 1px dashed #B9B9B9; | |||
| padding: 4px; | |||
| } | |||
| .lesson .content { | |||
| padding: 6px; | |||
| color: #666666; | |||
| } | |||
| .lesson .foot { | |||
| padding: 6px; | |||
| color: #777; | |||
| } | |||
| .lesson .font-darkblue { | |||
| color: #006699; | |||
| font-weight: bold; | |||
| } | |||
| .lesson .font-lightblue { | |||
| color: #008BD1; | |||
| font-weight: bold; | |||
| } | |||
| .lesson .font-gray { | |||
| color: #888; | |||
| font-size: 12px; | |||
| } | |||
| </style> | |||
| </head> | |||
| <body> | |||
| <div class="lesson"> | |||
| <div class="container"> | |||
| <div class="title">云课程考试平台</div> | |||
| <div class="content"> | |||
| <p class="headline"><b>亲爱的用户,你好!</b></p> | |||
| <b class="top"><b class="xb1"></b><b class="xb2"></b><b class="xb3"></b><b class="xb4"></b></b> | |||
| <div class="lesson-content"> | |||
| <div class="content"> | |||
| <p><!--/*@thymesVar id="type" type="java.lang.String"*/--> | |||
| <b th:text="'欢迎您' + ${type}">欢迎您注册</b><span id="userName" class="font-darkblue"> 云课程考试平台</span> | |||
| </p> | |||
| <p><b><!--/*@thymesVar id="type" type="java.lang.String"*/--> | |||
| <b th:text="${type}">你的邮件</b>的验证码:</b><span class="font-lightblue"><span style="border-bottom: 1px dashed rgb(204, 204, 204); z-index: 1; position: static;"> <!--/*@thymesVar id="code" type="java.lang.String"*/--> | |||
| <b th:text="${code}">验证码</b></span></span><br><span | |||
| class="font-gray">(请输入该验证码完成 <span></span> 验证,验证码 | |||
| <!--/*@thymesVar id="time" type="java.lang.Integer"*/--> | |||
| <b th:text="${time}">10</b> 分钟内有效!)</span></p> | |||
| <div class="line">如果您未申请云课程学习平台 <!--/*@thymesVar id="type" type="java.lang.String"*/--> | |||
| <span th:text="${type}">$(type)</span> 服务,请忽略该邮件。 | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <b class="bottom"><b class="xb4"></b><b class="xb3"></b><b class="xb2"></b><b class="xb1"></b></b> | |||
| <p class="foot">如果仍有问题,请联系我们的管理员: <span | |||
| style="border-bottom: 1px dashed rgb(204, 204, 204); z-index: 1; position: static;">000-00000000 | |||
| </span></p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,22 @@ | |||
| package com.xkcoding.oauth; | |||
| import org.junit.jupiter.api.Test; | |||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |||
| import org.springframework.security.crypto.password.PasswordEncoder; | |||
| /** | |||
| * . | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午3:51 | |||
| */ | |||
| public class PasswordEncodeTest { | |||
| private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); | |||
| @Test | |||
| public void getPasswordWhenPassed() { | |||
| System.out.println(passwordEncoder.encode("oauth2")); | |||
| System.out.println(passwordEncoder.encode("123456")); | |||
| } | |||
| } | |||
| @@ -0,0 +1,125 @@ | |||
| package com.xkcoding.oauth.oauth; | |||
| import org.junit.jupiter.api.BeforeEach; | |||
| import org.junit.jupiter.api.Test; | |||
| import org.springframework.http.HttpHeaders; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.http.ResponseEntity; | |||
| import org.springframework.security.oauth2.client.OAuth2RestTemplate; | |||
| import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException; | |||
| import org.springframework.security.oauth2.client.token.DefaultAccessTokenRequest; | |||
| import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeAccessTokenProvider; | |||
| import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeResourceDetails; | |||
| import org.springframework.util.LinkedMultiValueMap; | |||
| import org.springframework.util.MultiValueMap; | |||
| import java.net.URI; | |||
| import java.util.Arrays; | |||
| import java.util.Collections; | |||
| import java.util.regex.Matcher; | |||
| import java.util.regex.Pattern; | |||
| import static com.xkcoding.oauth.oauth.AuthorizationServerInfo.getUrl; | |||
| import static org.junit.jupiter.api.Assertions.*; | |||
| /** | |||
| * 授权码模式测试. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午8:43 | |||
| */ | |||
| public class AuthorizationCodeGrantTests { | |||
| private AuthorizationCodeResourceDetails resource = new AuthorizationCodeResourceDetails(); | |||
| private AuthorizationServerInfo authorizationServerInfo = new AuthorizationServerInfo(); | |||
| @BeforeEach | |||
| void setUp() { | |||
| resource.setAccessTokenUri(getUrl("/oauth/token")); | |||
| resource.setClientId("oauth2"); | |||
| resource.setId("oauth2"); | |||
| resource.setScope(Arrays.asList("READ", "WRITE")); | |||
| resource.setAccessTokenUri(getUrl("/oauth/token")); | |||
| resource.setUserAuthorizationUri(getUrl("/oauth/authorize")); | |||
| } | |||
| @Test | |||
| void testCannotConnectWithoutToken() { | |||
| OAuth2RestTemplate template = new OAuth2RestTemplate(resource); | |||
| assertThrows(UserRedirectRequiredException.class, | |||
| () -> template.getForObject(getUrl("/oauth/me"), String.class)); | |||
| } | |||
| @Test | |||
| void testAttemptedTokenAcquisitionWithNoRedirect() { | |||
| AuthorizationCodeAccessTokenProvider provider = new AuthorizationCodeAccessTokenProvider(); | |||
| assertThrows(UserRedirectRequiredException.class, | |||
| () -> provider.obtainAccessToken(resource, new DefaultAccessTokenRequest())); | |||
| } | |||
| /** | |||
| * 这里不使用他提供的是因为很多地方不符合我们的需要 | |||
| * 比如 csrf,比如许多有些是自己自定义的端点这些 | |||
| * 所以只有我们一步一步的来进行测试拿到授权码 | |||
| */ | |||
| @Test | |||
| void testCodeAcquisitionWithCorrectContext() { | |||
| // 1. 请求登录页面获取 _csrf 的 value 以及 cookie | |||
| ResponseEntity<String> page = authorizationServerInfo.getForString("/oauth/login"); | |||
| assertNotNull(page.getBody()); | |||
| String cookie = page.getHeaders().getFirst("Set-Cookie"); | |||
| HttpHeaders headers = new HttpHeaders(); | |||
| headers.set("Cookie", cookie); | |||
| Matcher matcher = Pattern.compile("(?s).*name=\"_csrf\".*?value=\"([^\"]+).*").matcher(page.getBody()); | |||
| assertTrue(matcher.find()); | |||
| // 2. 添加表单数据 | |||
| MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); | |||
| form.add("username", "admin"); | |||
| form.add("password", "123456"); | |||
| form.add("_csrf", matcher.group(1)); | |||
| // 3. 登录授权并获取登录成功的 cookie | |||
| ResponseEntity<Void> response = authorizationServerInfo | |||
| .postForStatus("/authorization/form", headers, form); | |||
| assertNotNull(response); | |||
| cookie = response.getHeaders().getFirst("Set-Cookie"); | |||
| headers = new HttpHeaders(); | |||
| headers.set("Cookie", cookie); | |||
| headers.setAccept(Collections.singletonList(MediaType.ALL)); | |||
| // 4. 请求到 确认授权页面 ,获取确认授权页面的 _csrf 的 value | |||
| ResponseEntity<String> confirm = authorizationServerInfo | |||
| .getForString("/oauth/authorize?response_type=code&client_id=oauth2&redirect_uri=http://example.com&scope=READ", headers); | |||
| headers = confirm.getHeaders(); | |||
| // 确认过一次后,后面都会自动确认了,这里判断下是不是重定向请求 | |||
| // 如果不是,就表示是第一次,需要确认授权 | |||
| if (!confirm.getStatusCode().is3xxRedirection()) { | |||
| assertNotNull(confirm.getBody()); | |||
| Matcher matcherConfirm = Pattern.compile("(?s).*name=\"_csrf\".*?value=\"([^\"]+).*").matcher(confirm.getBody()); | |||
| assertTrue(matcherConfirm.find()); | |||
| headers = new HttpHeaders(); | |||
| headers.set("Cookie", cookie); | |||
| headers.setAccept(Collections.singletonList(MediaType.ALL)); | |||
| // 5. 构建 同意授权 的表单 | |||
| form = new LinkedMultiValueMap<>(); | |||
| form.add("user_oauth_approval", "true"); | |||
| form.add("scope.READ", "true"); | |||
| form.add("_csrf", matcherConfirm.group(1)); | |||
| // 6. 请求授权,获取 授权码 | |||
| headers = authorizationServerInfo.postForHeaders("/oauth/authorize", form, headers); | |||
| } | |||
| URI location = headers.getLocation(); | |||
| assertNotNull(location); | |||
| String query = location.getQuery(); | |||
| assertNotNull(query); | |||
| String[] result = query.split("="); | |||
| assertEquals(2, result.length); | |||
| System.out.println(result[1]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,94 @@ | |||
| package com.xkcoding.oauth.oauth; | |||
| import org.springframework.http.*; | |||
| import org.springframework.http.client.ClientHttpRequest; | |||
| import org.springframework.http.client.ClientHttpResponse; | |||
| import org.springframework.http.client.SimpleClientHttpRequestFactory; | |||
| import org.springframework.util.MultiValueMap; | |||
| import org.springframework.web.client.RequestCallback; | |||
| import org.springframework.web.client.ResponseErrorHandler; | |||
| import org.springframework.web.client.RestTemplate; | |||
| import java.io.IOException; | |||
| import java.net.HttpURLConnection; | |||
| /** | |||
| * 授权服务器工具类. | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午8:44 | |||
| */ | |||
| @SuppressWarnings("all") | |||
| public class AuthorizationServerInfo { | |||
| public static final String HOST = "http://127.0.0.1:8080"; | |||
| private RestTemplate client; | |||
| public AuthorizationServerInfo() { | |||
| client = new RestTemplate(); | |||
| client.setRequestFactory(new SimpleClientHttpRequestFactory() { | |||
| @Override | |||
| protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException { | |||
| super.prepareConnection(connection, httpMethod); | |||
| connection.setInstanceFollowRedirects(false); | |||
| } | |||
| }); | |||
| client.setErrorHandler(new ResponseErrorHandler() { | |||
| public boolean hasError(ClientHttpResponse response) { | |||
| return false; | |||
| } | |||
| public void handleError(ClientHttpResponse response) { | |||
| } | |||
| }); | |||
| } | |||
| public ResponseEntity<String> getForString(String path, final HttpHeaders headers) { | |||
| return client.exchange(getUrl(path), HttpMethod.GET, new HttpEntity<>(null, headers), String.class); | |||
| } | |||
| public ResponseEntity<String> getForString(String path) { | |||
| return getForString(path, new HttpHeaders()); | |||
| } | |||
| public ResponseEntity<Void> postForStatus(String path, HttpHeaders headers, MultiValueMap<String, String> formData) { | |||
| HttpHeaders actualHeaders = new HttpHeaders(); | |||
| actualHeaders.putAll(headers); | |||
| actualHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); | |||
| return client.exchange(getUrl(path), HttpMethod.POST, | |||
| new HttpEntity<>(formData, actualHeaders), (Class<Void>) null); | |||
| } | |||
| public static String getUrl(String path) { | |||
| return HOST + path; | |||
| } | |||
| public HttpHeaders postForHeaders(String path, MultiValueMap<String, String> formData, final HttpHeaders headers) { | |||
| RequestCallback requestCallback = new NullRequestCallback(); | |||
| if (headers != null) { | |||
| requestCallback = request -> request.getHeaders().putAll(headers); | |||
| } | |||
| StringBuilder builder = new StringBuilder(getUrl(path)); | |||
| if (!path.contains("?")) { | |||
| builder.append("?"); | |||
| } else { | |||
| builder.append("&"); | |||
| } | |||
| for (String key : formData.keySet()) { | |||
| for (String value : formData.get(key)) { | |||
| builder.append(key).append("=").append(value); | |||
| builder.append("&"); | |||
| } | |||
| } | |||
| builder.deleteCharAt(builder.length() - 1); | |||
| return client.execute(builder.toString(), HttpMethod.POST, requestCallback, | |||
| HttpMessage::getHeaders); | |||
| } | |||
| private static final class NullRequestCallback implements RequestCallback { | |||
| public void doWithRequest(ClientHttpRequest request) { | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,39 @@ | |||
| package com.xkcoding.oauth.oauth; | |||
| import org.junit.jupiter.api.Test; | |||
| import org.springframework.security.oauth2.client.OAuth2RestTemplate; | |||
| import org.springframework.security.oauth2.client.token.grant.password.ResourceOwnerPasswordResourceDetails; | |||
| import org.springframework.security.oauth2.common.OAuth2AccessToken; | |||
| import java.util.Arrays; | |||
| import static com.xkcoding.oauth.oauth.AuthorizationServerInfo.getUrl; | |||
| import static org.junit.jupiter.api.Assertions.*; | |||
| /** | |||
| * . | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午9:14 | |||
| */ | |||
| public class ResourceOwnerPasswordGrantTests { | |||
| @Test | |||
| void testConnectDirectlyToResourceServer() { | |||
| assertNotNull(accessToken()); | |||
| } | |||
| public static String accessToken() { | |||
| ResourceOwnerPasswordResourceDetails resource = new ResourceOwnerPasswordResourceDetails(); | |||
| resource.setAccessTokenUri(getUrl("/oauth/token")); | |||
| resource.setClientId("oauth2"); | |||
| resource.setClientSecret("oauth2"); | |||
| resource.setId("oauth2"); | |||
| resource.setScope(Arrays.asList("READ", "WRITE")); | |||
| resource.setUsername("admin"); | |||
| resource.setPassword("123456"); | |||
| OAuth2RestTemplate template = new OAuth2RestTemplate(resource); | |||
| OAuth2AccessToken accessToken = template.getAccessToken(); | |||
| return accessToken.getValue(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,26 @@ | |||
| package com.xkcoding.oauth.repostiory; | |||
| import org.junit.jupiter.api.Test; | |||
| import org.springframework.beans.factory.annotation.Autowired; | |||
| import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; | |||
| import static org.junit.jupiter.api.Assertions.assertNotNull; | |||
| /** | |||
| * . | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午1:10 | |||
| */ | |||
| @DataJpaTest | |||
| public class SysClientDetailsTest { | |||
| @Autowired | |||
| private SysClientDetailsRepository sysClientDetailsRepository; | |||
| @Test | |||
| public void autowiredSuccessWhenPassed() { | |||
| assertNotNull(sysClientDetailsRepository); | |||
| } | |||
| } | |||
| @@ -0,0 +1,40 @@ | |||
| package com.xkcoding.oauth.repostiory; | |||
| import com.xkcoding.oauth.entity.SysUser; | |||
| import org.junit.jupiter.api.DisplayName; | |||
| import org.junit.jupiter.api.Test; | |||
| import org.springframework.beans.factory.annotation.Autowired; | |||
| import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; | |||
| import java.util.Optional; | |||
| import static org.junit.jupiter.api.Assertions.*; | |||
| /** | |||
| * . | |||
| * | |||
| * @author <a href="https://echocow.cn">EchoCow</a> | |||
| * @date 2020/1/6 下午1:25 | |||
| */ | |||
| @DataJpaTest | |||
| public class SysUserRepositoryTest { | |||
| @Autowired | |||
| private SysUserRepository sysUserRepository; | |||
| @Test | |||
| public void autowiredSuccessWhenPassed() { | |||
| assertNotNull(sysUserRepository); | |||
| } | |||
| @Test | |||
| @DisplayName("测试关联查询") | |||
| public void queryUserAndRoleWhenPassed() { | |||
| Optional<SysUser> admin = sysUserRepository.findFirstByUsername("admin"); | |||
| assertTrue(admin.isPresent()); | |||
| SysUser sysUser = admin.orElseGet(SysUser::new); | |||
| assertNotNull(sysUser.getRoles()); | |||
| assertEquals(1, sysUser.getRoles().size()); | |||
| } | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| server: | |||
| port: 8080 | |||
| servlet: | |||
| context-path: /demo | |||
| spring: | |||
| datasource: | |||
| url: jdbc:h2:mem:oauth2?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE | |||
| username: root | |||
| password: 123456 | |||
| jpa: | |||
| hibernate: | |||
| ddl-auto: create-drop | |||
| show-sql: true | |||
| properties: | |||
| hibernate: | |||
| format_sql: true | |||
| logging: | |||
| level: | |||
| org.springframework.security: debug | |||
| @@ -0,0 +1,10 @@ | |||
| -- 测试数据 | |||
| INSERT INTO sys_client_details (id, access_token_validity_seconds, authorizations, auto_approve_scopes, client_id, client_secret, grant_types, redirect_url, refresh_token_validity_seconds, resource_ids, scopes) VALUES (1, 6000, null, null, 'oauth2', '$2a$10$O8uM8kd5SbsuoITG3tBifOcarqqI8GP19vzbqDzVHP5ZV9yOfvpYS', 'authorization_code,password', 'http://example.com', 6000, 'oauth2', 'READ,WRITE'); | |||
| INSERT INTO sys_client_details (id, access_token_validity_seconds, authorizations, auto_approve_scopes, client_id, client_secret, grant_types, redirect_url, refresh_token_validity_seconds, resource_ids, scopes) VALUES (2, 6000, null, null, 'test', '$2a$10$O8uM8kd5SbsuoITG3tBifOcarqqI8GP19vzbqDzVHP5ZV9yOfvpYS', 'authorization_code,password', 'http://example.com', 6000, 'test', 'READ'); | |||
| INSERT INTO sys_client_details (id, access_token_validity_seconds, authorizations, auto_approve_scopes, client_id, client_secret, grant_types, redirect_url, refresh_token_validity_seconds, resource_ids, scopes) VALUES (3, 6000, null, null, 'test', '$2a$10$O8uM8kd5SbsuoITG3tBifOcarqqI8GP19vzbqDzVHP5ZV9yOfvpYS', 'authorization_code,password', 'http://example.com', 6000, 'error', 'READ'); | |||
| INSERT INTO sys_role (id, name, description) VALUES (1, 'ROLE_ADMIN', '管理员'); | |||
| INSERT INTO sys_role (id, name, description) VALUES (2, 'ROLE_TEST', '测试'); | |||
| INSERT INTO sys_user (id, username, password) VALUES (1, 'admin', '$2a$10$xLH.pDNz3d2frOBQ6Gc.wuHY4ghwlSyFDgy0Ta.psXmm1YJjNaV1G'); | |||
| INSERT INTO sys_user (id, username, password) VALUES (2, 'test', '$2a$10$xLH.pDNz3d2frOBQ6Gc.wuHY4ghwlSyFDgy0Ta.psXmm1YJjNaV1G'); | |||
| INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1); | |||
| INSERT INTO sys_user_role (user_id, role_id) VALUES (2, 2); | |||
| @@ -0,0 +1,40 @@ | |||
| create table sys_client_details | |||
| ( | |||
| id bigint auto_increment primary key, | |||
| access_token_validity_seconds int null, | |||
| authorizations varchar(255) null, | |||
| auto_approve_scopes varchar(255) null, | |||
| client_id varchar(255) null, | |||
| client_secret varchar(255) null, | |||
| grant_types varchar(255) null, | |||
| redirect_url varchar(255) null, | |||
| refresh_token_validity_seconds int null, | |||
| resource_ids varchar(255) null, | |||
| scopes varchar(255) null | |||
| ); | |||
| create table sys_role | |||
| ( | |||
| id bigint auto_increment primary key, | |||
| name varchar(55) not null, | |||
| description varchar(55) null | |||
| ); | |||
| create table sys_user | |||
| ( | |||
| id bigint auto_increment primary key, | |||
| username varchar(55) not null, | |||
| password varchar(128) not null | |||
| ); | |||
| create table sys_user_role | |||
| ( | |||
| id bigint auto_increment primary key, | |||
| user_id bigint not null, | |||
| role_id bigint not null, | |||
| constraint sys_user_role_sys_role_id_fk | |||
| foreign key (role_id) references sys_role (id), | |||
| constraint sys_user_role_sys_user_id_fk | |||
| foreign key (user_id) references sys_user (id) | |||
| ); | |||
| @@ -1,4 +0,0 @@ | |||
| server: | |||
| port: 8080 | |||
| servlet: | |||
| context-path: /demo | |||
| @@ -1,17 +0,0 @@ | |||
| package com.xkcoding.oauth; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.springframework.boot.test.context.SpringBootTest; | |||
| import org.springframework.test.context.junit4.SpringRunner; | |||
| @RunWith(SpringRunner.class) | |||
| @SpringBootTest | |||
| public class SpringBootDemoOauthApplicationTests { | |||
| @Test | |||
| public void contextLoads() { | |||
| } | |||
| } | |||