From 467ec052ebfdcb24c902ce8b94384685e511029e Mon Sep 17 00:00:00 2001 From: tanghc Date: Sat, 27 Apr 2019 18:03:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BA=94=E7=94=A8=E6=8E=88?= =?UTF-8?q?=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.md | 4 + sop-1.6.0.sql | 16 + sop-admin/sop-admin-server/pom.xml | 2 +- sop-common/pom.xml | 2 +- sop-common/sop-gateway-common/pom.xml | 4 +- sop-common/sop-service-common/pom.xml | 4 +- sop-example/pom.xml | 1 + sop-example/sop-auth/.gitignore | 29 ++ sop-example/sop-auth/pom.xml | 127 ++++++++ sop-example/sop-auth/readme.md | 9 + .../gitee/sop/sopauth/SopAuthApplication.java | 15 + .../gitee/sop/sopauth/auth/AccessToken.java | 44 +++ .../gitee/sop/sopauth/auth/AppIdManager.java | 14 + .../sop/sopauth/auth/FetchTokenParam.java | 30 ++ .../sop/sopauth/auth/FetchTokenResult.java | 39 +++ .../gitee/sop/sopauth/auth/OAuth2Config.java | 46 +++ .../gitee/sop/sopauth/auth/OAuth2Manager.java | 93 ++++++ .../gitee/sop/sopauth/auth/OAuth2Service.java | 44 +++ .../com/gitee/sop/sopauth/auth/OpenUser.java | 22 ++ .../gitee/sop/sopauth/auth/RefreshToken.java | 40 +++ .../com/gitee/sop/sopauth/auth/TokenPair.java | 29 ++ .../auth/exception/LoginErrorException.java | 25 ++ .../sopauth/auth/impl/AppIdManagerImpl.java | 33 ++ .../sopauth/auth/impl/OAuth2ManagerCache.java | 135 ++++++++ .../sopauth/auth/impl/OAuth2ManagerRedis.java | 171 +++++++++++ .../sopauth/auth/impl/OAuth2ServiceImpl.java | 284 +++++++++++++++++ .../sop/sopauth/config/OpenServiceConfig.java | 20 ++ .../controller/CallbackController.java | 54 ++++ .../sopauth/controller/OAuth2Controller.java | 85 ++++++ .../com/gitee/sop/sopauth/entity/IsvInfo.java | 32 ++ .../gitee/sop/sopauth/entity/UserInfo.java | 48 +++ .../sop/sopauth/mapper/IsvInfoMapper.java | 11 + .../sop/sopauth/mapper/UserInfoMapper.java | 11 + .../src/main/resources/application-dev.yml | 35 +++ .../src/main/resources/application.yml | 3 + .../main/resources/templates/oauth2login.html | 19 ++ .../gitee/sop/sopauth/RefreshTokenTest.java | 40 +++ .../sop/sopauth/SopAuthApplicationTests.java | 16 + sop-example/sop-book/sop-book-web/pom.xml | 2 +- sop-example/sop-easyopen/pom.xml | 2 +- sop-example/sop-springmvc/pom.xml | 2 +- sop-example/sop-story/sop-story-web/pom.xml | 2 +- sop-gateway/pom.xml | 2 +- .../sop/sdk/model/OpenAuthTokenAppModel.java | 28 ++ .../sdk/request/OpenAuthTokenAppRequest.java | 13 + .../sop/sdk/response/GetStoryResponse.java | 2 + .../response/OpenAuthTokenAppResponse.java | 30 ++ .../website-front/assets/image/auth.png | Bin 0 -> 79808 bytes sop-website/website-front/pages/doc/auth.html | 288 ++++++++++++++++++ sop-website/website-front/pages/doc/doc.html | 2 +- sop.sql | 19 +- 51 files changed, 2014 insertions(+), 14 deletions(-) create mode 100644 sop-1.6.0.sql create mode 100644 sop-example/sop-auth/.gitignore create mode 100644 sop-example/sop-auth/pom.xml create mode 100644 sop-example/sop-auth/readme.md create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/SopAuthApplication.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/AccessToken.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/AppIdManager.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/FetchTokenParam.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/FetchTokenResult.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Config.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Manager.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Service.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OpenUser.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/RefreshToken.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/TokenPair.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/exception/LoginErrorException.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/AppIdManagerImpl.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ManagerCache.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ManagerRedis.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ServiceImpl.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/config/OpenServiceConfig.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/controller/CallbackController.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/controller/OAuth2Controller.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/entity/IsvInfo.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/entity/UserInfo.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/mapper/IsvInfoMapper.java create mode 100644 sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/mapper/UserInfoMapper.java create mode 100644 sop-example/sop-auth/src/main/resources/application-dev.yml create mode 100644 sop-example/sop-auth/src/main/resources/application.yml create mode 100644 sop-example/sop-auth/src/main/resources/templates/oauth2login.html create mode 100644 sop-example/sop-auth/src/test/java/com/gitee/sop/sopauth/RefreshTokenTest.java create mode 100644 sop-example/sop-auth/src/test/java/com/gitee/sop/sopauth/SopAuthApplicationTests.java create mode 100644 sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/model/OpenAuthTokenAppModel.java create mode 100644 sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/request/OpenAuthTokenAppRequest.java create mode 100644 sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/response/OpenAuthTokenAppResponse.java create mode 100644 sop-website/website-front/assets/image/auth.png create mode 100644 sop-website/website-front/pages/doc/auth.html diff --git a/changelog.md b/changelog.md index 194c729c..40a1db58 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,9 @@ # changelog +## 1.6.0 + +- 新增应用授权 + ## 1.5.0 - admin新增signType字段 diff --git a/sop-1.6.0.sql b/sop-1.6.0.sql new file mode 100644 index 00000000..e5ea8b4a --- /dev/null +++ b/sop-1.6.0.sql @@ -0,0 +1,16 @@ +DROP TABLE IF EXISTS `user_info`; + +CREATE TABLE `user_info` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(64) NOT NULL DEFAULT '' COMMENT '用户名', + `password` varchar(128) NOT NULL DEFAULT '' COMMENT '密码', + `nickname` varchar(64) NOT NULL DEFAULT '' COMMENT '昵称', + `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_unamepwd` (`username`,`password`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户信息表'; + + +INSERT INTO `user_info` ( `username`, `password`, `nickname`, `gmt_create`, `gmt_modified`) VALUES + ('zhangsan','123456','张三','2019-04-27 08:32:57','2019-04-27 08:32:57'); diff --git a/sop-admin/sop-admin-server/pom.xml b/sop-admin/sop-admin-server/pom.xml index 71c4a94d..67b78391 100644 --- a/sop-admin/sop-admin-server/pom.xml +++ b/sop-admin/sop-admin-server/pom.xml @@ -43,7 +43,7 @@ net.oschina.durcframework fastmybatis-spring-boot-starter - 1.6.0 + 1.7.1 mysql diff --git a/sop-common/pom.xml b/sop-common/pom.xml index a55f6ed3..0a62c6a1 100644 --- a/sop-common/pom.xml +++ b/sop-common/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.gitee.sop sop-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT pom diff --git a/sop-common/sop-gateway-common/pom.xml b/sop-common/sop-gateway-common/pom.xml index 7735edd4..56c83526 100644 --- a/sop-common/sop-gateway-common/pom.xml +++ b/sop-common/sop-gateway-common/pom.xml @@ -5,11 +5,11 @@ com.gitee.sop sop-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT ../pom.xml sop-gateway-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT jar sop-gateway-common diff --git a/sop-common/sop-service-common/pom.xml b/sop-common/sop-service-common/pom.xml index c947ce61..92e6879a 100644 --- a/sop-common/sop-service-common/pom.xml +++ b/sop-common/sop-service-common/pom.xml @@ -6,11 +6,11 @@ com.gitee.sop sop-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT ../pom.xml sop-service-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT jar sop-service-common diff --git a/sop-example/pom.xml b/sop-example/pom.xml index a3efa857..58a69ccd 100644 --- a/sop-example/pom.xml +++ b/sop-example/pom.xml @@ -9,6 +9,7 @@ pom + sop-auth sop-story sop-book sop-easyopen diff --git a/sop-example/sop-auth/.gitignore b/sop-example/sop-auth/.gitignore new file mode 100644 index 00000000..153c9335 --- /dev/null +++ b/sop-example/sop-auth/.gitignore @@ -0,0 +1,29 @@ +HELP.md +/target/ +!.mvn/wrapper/maven-wrapper.jar + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +/build/ + +### VS Code ### +.vscode/ diff --git a/sop-example/sop-auth/pom.xml b/sop-example/sop-auth/pom.xml new file mode 100644 index 00000000..e5f11959 --- /dev/null +++ b/sop-example/sop-auth/pom.xml @@ -0,0 +1,127 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.1.2.RELEASE + + + com.gitee.sop + sop-auth + 1.0.0-SNAPSHOT + sop-auth + 授权程序 + + + 1.8 + Greenwich.RELEASE + 0.31 + + + + + + + com.gitee.sop + sop-service-common + 1.6.0-SNAPSHOT + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + + + com.gitee.sop + sdk-java + 1.0.0-SNAPSHOT + + + com.squareup.okhttp3 + okhttp + 3.14.0 + + + + net.oschina.durcframework + fastmybatis-spring-boot-starter + 1.7.1 + + + mysql + mysql-connector-java + runtime + + + + + org.apache.oltu.oauth2 + org.apache.oltu.oauth2.authzserver + ${oltu.version} + + + org.apache.oltu.oauth2 + org.apache.oltu.oauth2.resourceserver + ${oltu.version} + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + com.google.guava + guava + 27.1-jre + compile + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/sop-example/sop-auth/readme.md b/sop-example/sop-auth/readme.md new file mode 100644 index 00000000..71f3b53c --- /dev/null +++ b/sop-example/sop-auth/readme.md @@ -0,0 +1,9 @@ +# 应用授权 + +- 启动注册中心、网关、本服务(sop-auth) +- 浏览器访问:http://localhost:8087/oauth2/appToAppAuth?app_id=2019032617262200001&redirect_uri=http%3a%2f%2flocalhost%3a8087%2foauth2callback +- 输入用户名密码登录,这里是`zhangsan/123456` + +授权接口在`OAuth2Controller`中,查看回调在`CallbackController`中 + +token的维护,重点关注`OAuth2ManagerRedis.java` diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/SopAuthApplication.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/SopAuthApplication.java new file mode 100644 index 00000000..29595038 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/SopAuthApplication.java @@ -0,0 +1,15 @@ +package com.gitee.sop.sopauth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@EnableDiscoveryClient +@SpringBootApplication +public class SopAuthApplication { + + public static void main(String[] args) { + SpringApplication.run(SopAuthApplication.class, args); + } + +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/AccessToken.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/AccessToken.java new file mode 100644 index 00000000..83cc9925 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/AccessToken.java @@ -0,0 +1,44 @@ +package com.gitee.sop.sopauth.auth; + +/** + * @author tanghc + */ +public class AccessToken { + /** 过期时间,毫秒 */ + private long expireTimeMillis; + private OpenUser openUser; + + /** + * @param expireIn 有效时长,秒 + * @param openUser + */ + public AccessToken(long expireIn, OpenUser openUser) { + super(); + if(expireIn <= 0) { + throw new IllegalArgumentException("expireIn必须大于0"); + } + this.expireTimeMillis = System.currentTimeMillis() + (expireIn * 1000); + this.openUser = openUser; + } + + public boolean isExpired() { + return expireTimeMillis < System.currentTimeMillis(); + } + + public long getExpireTimeMillis() { + return expireTimeMillis; + } + + public void setExpireTimeMillis(long expireTimeMillis) { + this.expireTimeMillis = expireTimeMillis; + } + + public OpenUser getOpenUser() { + return openUser; + } + + public void setOpenUser(OpenUser openUser) { + this.openUser = openUser; + } + +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/AppIdManager.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/AppIdManager.java new file mode 100644 index 00000000..4d9eb26d --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/AppIdManager.java @@ -0,0 +1,14 @@ +package com.gitee.sop.sopauth.auth; + +/** + * @author tanghc + */ +public interface AppIdManager { + /** + * 是否是合法的appId + * + * @param appId + * @return true:合法 + */ + boolean isValidAppId(String appId); +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/FetchTokenParam.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/FetchTokenParam.java new file mode 100644 index 00000000..2acc1a16 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/FetchTokenParam.java @@ -0,0 +1,30 @@ +package com.gitee.sop.sopauth.auth; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 获取accessToken参数对象 + * @author tanghc + */ +@Data +public class FetchTokenParam { + /** + * 授权类型。
+ * 如果使用app_auth_code换取token,则为authorization_code, + * 如果使用refresh_token换取新的token,则为refresh_token + */ + @NotBlank(message = "授权类型不能为空") + private String grant_type; + + /** + * 授权码.与refresh_token二选一,用户对应用授权后得到,即第一步中开发者获取到的app_auth_code值 + */ + private String code; + + /** + * 刷新令牌.与code二选一,可为空,刷新令牌时使用 + */ + private String refresh_token; +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/FetchTokenResult.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/FetchTokenResult.java new file mode 100644 index 00000000..be4360b2 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/FetchTokenResult.java @@ -0,0 +1,39 @@ +package com.gitee.sop.sopauth.auth; + +import lombok.Data; + +/** + * 使用app_auth_code换取app_auth_token返回结果 + * @author tanghc + */ +@Data +public class FetchTokenResult { + /** + * 授权令牌 + */ + private String app_auth_token; + + /** + * 用户id + */ + private String user_id; + + /** + * 令牌有效期. -1:永久有效 + */ + private long expires_in = -1; + + /** + * 刷新令牌有效期. -1:永久有效 + */ + private long re_expires_in = -1; + + /** + * 刷新令牌时使用。 + * 刷新令牌后,我们会保证老的app_auth_token从刷新开始10分钟内可继续使用,请及时替换为最新token + */ + private String app_refresh_token; + + private String error; + private String error_description; +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Config.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Config.java new file mode 100644 index 00000000..6a021dc0 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Config.java @@ -0,0 +1,46 @@ +package com.gitee.sop.sopauth.auth; + +import lombok.Data; + +/** + * @author tanghc + */ +@Data +public class OAuth2Config { + + private static OAuth2Config instance = new OAuth2Config(); + + public static OAuth2Config getInstance() { + return instance; + } + + public static void setInstance(OAuth2Config instance) { + OAuth2Config.instance = instance; + } + + /** + * 应用授权的app_auth_code唯一的;app_auth_code使用一次后失效,一天(从生成app_auth_code开始的24小时)未被使用自动过期; + * app_auth_token永久有效。 + */ + private int codeTimeoutSeconds = 86400; + /** + * accessToken有效时间,单位秒 + * -1 永久有效 + */ + private int accessTokenExpiresIn = -1; + + /** + * app_refresh_token有效时间,单位秒,accessToken有效时间的3倍。当小于0,永久有效 + */ + private int refreshTokenExpiresIn = accessTokenExpiresIn * 3; + + /** + * 刷新令牌后,我们会保证老的app_auth_token从刷新开始10分钟内可继续使用,请及时替换为最新token + */ + private int afterRefreshExpiresIn = 60 * 10; + + /** + * 登录视图页面用于,mvc视图,如:loginView + */ + private String oauth2LoginUri = "/oauth2/login"; +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Manager.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Manager.java new file mode 100644 index 00000000..9f5d1c06 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Manager.java @@ -0,0 +1,93 @@ +package com.gitee.sop.sopauth.auth; + + +import com.gitee.sop.sopauth.auth.exception.LoginErrorException; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +/** + * 认证服务,需要自己实现 + * @author tanghc + * + */ +public interface OAuth2Manager { + + /** + * 添加 auth code + * + * @param authCode + * code值 + * @param authUser + * 用户 + */ + void addAuthCode(String authCode, OpenUser authUser); + + /** + * 添加accessToken + * @param accessToken token值 + * @param refreshToken refreshToken + * @param authUser 用户 + */ + void addAccessToken(String accessToken, String refreshToken, OpenUser authUser); + + + /** + * 删除这个accessToken + * @param accessToken + */ + void removeAccessToken(String accessToken); + + /** + * 删除这个refreshToken + * @param refreshToken + */ + void removeRefreshToken(String refreshToken); + + /** + * 获取RefreshToken + * @param refreshToken + * @return 返回Token信息 + */ + RefreshToken getRefreshToken(String refreshToken); + + /** + * 验证auth code是否有效 + * + * @param authCode + * @return 无效返回false + */ + boolean checkAuthCode(String authCode); + + /** + * 根据auth code获取用户 + * + * @param authCode + * @return 返回用户 + */ + OpenUser getUserByAuthCode(String authCode); + + /** + * 根据access token获取用户名 + * + * @param accessToken + * token值 + * @return 返回用户 + */ + OpenUser getUserByAccessToken(String accessToken); + + /** + * 返回accessToken中追加的参数 + * @param user + * @return 返回追加的参数 + */ + Map getParam(OpenUser user); + + /** + * 用户登录,需判断是否已经登录 + * @param request + * @return 返回用户对象 + * @throws LoginErrorException 登录失败异常 + */ + OpenUser login(HttpServletRequest request) throws LoginErrorException; +} \ No newline at end of file diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Service.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Service.java new file mode 100644 index 00000000..ecc9d26e --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OAuth2Service.java @@ -0,0 +1,44 @@ +package com.gitee.sop.sopauth.auth; + +import org.apache.oltu.oauth2.common.exception.OAuthSystemException; +import org.apache.oltu.oauth2.common.message.OAuthResponse; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.URISyntaxException; + +/** + * @author tanghc + */ +public interface OAuth2Service { + + /** + * oauth2授权,获取code. + *
+     *  1、首先通过如http://localhost:8080/api/authorize?client_id=test&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Foauth2callback访问授权页面;
+        2、该控制器首先检查clientId是否正确;如果错误将返回相应的错误信息;
+        3、然后判断用户是否登录了,如果没有登录首先到登录页面登录;
+        4、登录成功后生成相应的code即授权码,然后重定向到客户端地址,如http://localhost:8080/oauth2callback?code=6d250650831fea227749f49a5b49ccad;在重定向到的地址中会带上code参数(授权码),接着客户端可以根据授权码去换取accessToken。
+     * 
+ * + * @param request req + * @param response resp + * @param authConfig 配置 + * @return 返回响应内容 + * @throws URISyntaxException + * @throws OAuthSystemException + */ + OAuthResponse authorize(HttpServletRequest request, HttpServletResponse response, OAuth2Config authConfig) + throws URISyntaxException, OAuthSystemException; + + + /** + * 通过code获取accessToken. + * @param fetchTokenParam + * @param authConfig 配置项 + * @return 返回响应内容 + * @throws URISyntaxException + * @throws OAuthSystemException + */ + FetchTokenResult accessToken(FetchTokenParam fetchTokenParam, OAuth2Config authConfig); +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OpenUser.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OpenUser.java new file mode 100644 index 00000000..5e7338b3 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/OpenUser.java @@ -0,0 +1,22 @@ +package com.gitee.sop.sopauth.auth; + +import java.io.Serializable; + +/** + * 如果要使用oauth2功能,自己的用户类需要实现这个对象 + * + * @author tanghc + */ +public interface OpenUser extends Serializable { + /** + * 返回用户id + * @return + */ + String getUserId(); + /** + * 返回用户名 + * + * @return 返回用户名 + */ + String getUsername(); +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/RefreshToken.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/RefreshToken.java new file mode 100644 index 00000000..acd04d54 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/RefreshToken.java @@ -0,0 +1,40 @@ +package com.gitee.sop.sopauth.auth; + +import java.io.Serializable; + +/** + * @author tanghc + */ +public class RefreshToken implements Serializable { + private static final long serialVersionUID = 1L; + + private String accessToken; + private OpenUser openUser; + + public RefreshToken(String accessToken, OpenUser openUser) { + super(); + this.accessToken = accessToken; + this.openUser = openUser; + } + + public RefreshToken() { + super(); + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public OpenUser getOpenUser() { + return openUser; + } + + public void setOpenUser(OpenUser openUser) { + this.openUser = openUser; + } + +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/TokenPair.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/TokenPair.java new file mode 100644 index 00000000..07e11214 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/TokenPair.java @@ -0,0 +1,29 @@ +package com.gitee.sop.sopauth.auth; + +/** + * 存放accessToken和refreshToken + * + * @author tanghc + * + */ +public class TokenPair { + private String accessToken; + private String refreshToken; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/exception/LoginErrorException.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/exception/LoginErrorException.java new file mode 100644 index 00000000..92e61d6a --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/exception/LoginErrorException.java @@ -0,0 +1,25 @@ +package com.gitee.sop.sopauth.auth.exception; + +/** + * @author tanghc + */ +public class LoginErrorException extends Exception { + private static final long serialVersionUID = -6721499454527023339L; + + public LoginErrorException() { + super(); + } + + public LoginErrorException(String message, Throwable cause) { + super(message, cause); + } + + public LoginErrorException(String message) { + super(message); + } + + public LoginErrorException(Throwable cause) { + super(cause); + } + +} \ No newline at end of file diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/AppIdManagerImpl.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/AppIdManagerImpl.java new file mode 100644 index 00000000..9c49954b --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/AppIdManagerImpl.java @@ -0,0 +1,33 @@ +package com.gitee.sop.sopauth.auth.impl; + +import com.gitee.sop.sopauth.auth.AppIdManager; +import com.gitee.sop.sopauth.entity.IsvInfo; +import com.gitee.sop.sopauth.mapper.IsvInfoMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * @author tanghc + */ +@Service +@Slf4j +public class AppIdManagerImpl implements AppIdManager { + + public static final int FORBIDDEN = 2; + @Autowired + private IsvInfoMapper isvInfoMapper; + + @Override + public boolean isValidAppId(String appId) { + IsvInfo isvInfo = isvInfoMapper.getByColumn("app_key", appId); + if (isvInfo == null) { + return false; + } + if (isvInfo.getStatus().intValue() == FORBIDDEN) { + log.error("appId已禁用:{}", appId); + return false; + } + return true; + } +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ManagerCache.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ManagerCache.java new file mode 100644 index 00000000..102ed522 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ManagerCache.java @@ -0,0 +1,135 @@ +package com.gitee.sop.sopauth.auth.impl; + +import com.gitee.fastmybatis.core.query.Query; +import com.gitee.sop.sopauth.auth.OAuth2Config; +import com.gitee.sop.sopauth.auth.OAuth2Manager; +import com.gitee.sop.sopauth.auth.OpenUser; +import com.gitee.sop.sopauth.auth.RefreshToken; +import com.gitee.sop.sopauth.auth.exception.LoginErrorException; +import com.gitee.sop.sopauth.entity.UserInfo; +import com.gitee.sop.sopauth.mapper.UserInfoMapper; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * oauth2管理,默认谷歌缓存实现,跟redis实现只能用一个。正式环境推荐使用redis保存 + * @author tanghc + * + */ +@Service +public class OAuth2ManagerCache implements OAuth2Manager { + /** + * 应用授权的app_auth_code唯一的;app_auth_code使用一次后失效,一天(从生成app_auth_code开始的24小时)未被使用自动过期; + * app_auth_token永久有效。 + */ + private int codeTimeoutSeconds = OAuth2Config.getInstance().getCodeTimeoutSeconds(); + /** + * accessToken过期时间 + * -1 永久有效 + */ + private int accessTokenTimeoutSeconds = OAuth2Config.getInstance().getAccessTokenExpiresIn(); + + private int refreshTokenTimeoutSeconds = OAuth2Config.getInstance().getRefreshTokenExpiresIn(); + + private LoadingCache codeCache = buildCache(codeTimeoutSeconds); + private LoadingCache accessTokenCache = buildCache(accessTokenTimeoutSeconds); + private LoadingCache refreshTokenCache = buildCache(refreshTokenTimeoutSeconds); + + @Autowired + private UserInfoMapper userInfoMapper; + + + private static LoadingCache buildCache(int timeout) { + CacheBuilder cacheBuilder = CacheBuilder.newBuilder(); + if (timeout > 0) { + cacheBuilder.expireAfterAccess(timeout, TimeUnit.SECONDS); + } + return cacheBuilder + .build(new CacheLoader() { + @Override + public T load(String key) throws Exception { + return null; + } + }); + } + + @Override + public void addAuthCode(String authCode, OpenUser authUser) { + codeCache.put(authCode, authUser); + } + + + @Override + public void addAccessToken(String accessToken, String refreshToken, OpenUser authUser) { + accessTokenCache.put(accessToken, authUser); + refreshTokenCache.put(refreshToken, new RefreshToken(accessToken, authUser)); + } + + @Override + public void removeAccessToken(String accessToken) { + accessTokenCache.asMap().remove(accessToken); + } + + @Override + public void removeRefreshToken(String refreshToken) { + refreshTokenCache.asMap().remove(refreshToken); + } + + @Override + public RefreshToken getRefreshToken(String refreshToken) { + return refreshTokenCache.getIfPresent(refreshToken); + } + + @Override + public boolean checkAuthCode(String authCode) { + return codeCache.asMap().containsKey(authCode); + } + + @Override + public OpenUser getUserByAuthCode(String authCode) { + return codeCache.getIfPresent(authCode); + } + + @Override + public OpenUser getUserByAccessToken(String accessToken) { + return accessTokenCache.getIfPresent(accessToken); + } + + @Override + public Map getParam(OpenUser user) { + Map map = new HashMap<>(); + map.put("username", user.getUsername()); + return map; + } + + @Override + public OpenUser login(HttpServletRequest request) throws LoginErrorException { + // 这里应该先检查用户有没有登录,如果登录直接返回openUser + String username = request.getParameter("username"); + String password = request.getParameter("password"); + if(StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) { + throw new LoginErrorException("用户名密码不能为空"); + } + + Query query = new Query(); + query.eq("username", username) + .eq("password", password); + UserInfo userInfo = userInfoMapper.getByQuery(query); + + if(userInfo == null) { + throw new LoginErrorException("用户名密码不正确"); + } + + return userInfo; + } + +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ManagerRedis.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ManagerRedis.java new file mode 100644 index 00000000..c22d98c2 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ManagerRedis.java @@ -0,0 +1,171 @@ +package com.gitee.sop.sopauth.auth.impl; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.gitee.fastmybatis.core.query.Query; +import com.gitee.sop.sopauth.auth.OAuth2Config; +import com.gitee.sop.sopauth.auth.OAuth2Manager; +import com.gitee.sop.sopauth.auth.OpenUser; +import com.gitee.sop.sopauth.auth.RefreshToken; +import com.gitee.sop.sopauth.auth.exception.LoginErrorException; +import com.gitee.sop.sopauth.entity.UserInfo; +import com.gitee.sop.sopauth.mapper.UserInfoMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * + * oauth2管理redis实现,这个类跟OAuth2ManagerCache类只能用一个, + * 如果要用这个类,注释掉OAuth2ManagerCache的@Service + * 启用这个类的@Service + */ +//@Service +public class OAuth2ManagerRedis implements OAuth2Manager { + + private static String CODE_PREFIX = "com.gitee.sop.oauth2_code:"; + private static String ACCESS_TOKEN_PREFIX = "com.gitee.sop.oauth2_access_token:"; + private static String REFRESH_TOKEN_PREFIX = "com.gitee.sop.oauth2_refresh_token:"; + + private int codeTimeoutSeconds = OAuth2Config.getInstance().getCodeTimeoutSeconds(); + + @Autowired + private StringRedisTemplate redisTemplate; + + @Autowired + private UserInfoMapper userInfoMapper; + + public static String getCodeKey(String code) { + return CODE_PREFIX + code; + } + + public static String getAccessTokenKey(String accessToken) { + return ACCESS_TOKEN_PREFIX + accessToken; + } + + public static String getRefreshTokenKey(String refreshToken) { + return REFRESH_TOKEN_PREFIX + refreshToken; + } + + @Override + public void addAuthCode(String authCode, OpenUser authUser) { + redisTemplate.opsForValue().set(getCodeKey(authCode), + JSON.toJSONString(authUser), + codeTimeoutSeconds, + TimeUnit.SECONDS); + } + + + @Override + public void addAccessToken(String accessToken, String refreshToken, OpenUser authUser) { + // 存accessToken + long expiresIn = OAuth2Config.getInstance().getAccessTokenExpiresIn(); + long reExpiresIn = OAuth2Config.getInstance().getRefreshTokenExpiresIn(); + if (expiresIn > 0) { + redisTemplate.opsForValue().set(getAccessTokenKey(accessToken), JSON.toJSONString(authUser), expiresIn, TimeUnit.SECONDS); + } else { + redisTemplate.opsForValue().set(getAccessTokenKey(accessToken), JSON.toJSONString(authUser)); + } + // 存refreshToken + if (reExpiresIn > 0) { + redisTemplate.opsForValue().set( + getRefreshTokenKey(refreshToken), + JSON.toJSONString(new RefreshToken(accessToken, authUser)), + reExpiresIn, // refreshToken过期时间 + TimeUnit.SECONDS); + } else { + redisTemplate.opsForValue().set( + getRefreshTokenKey(refreshToken), + JSON.toJSONString(new RefreshToken(accessToken, authUser))); + } + + } + + @Override + public void removeAccessToken(String accessToken) { + String accessTokenKey = getAccessTokenKey(accessToken); + int afterRefreshExpiresIn = OAuth2Config.getInstance().getAfterRefreshExpiresIn(); + redisTemplate.expire(accessTokenKey, afterRefreshExpiresIn, TimeUnit.SECONDS); + } + + @Override + public void removeRefreshToken(String refreshToken) { + redisTemplate.delete(getRefreshTokenKey(refreshToken)); + } + + @Override + public RefreshToken getRefreshToken(String refreshToken) { + String json = redisTemplate.opsForValue().get(getRefreshTokenKey(refreshToken)); + if(StringUtils.isEmpty(json)) { + return null; + } + JSONObject jsonObj = JSON.parseObject(json); + + String userJson = jsonObj.getString("openUser"); + UserInfo user = JSON.parseObject(userJson, UserInfo.class); + String accessToken = jsonObj.getString("accessToken"); + + return new RefreshToken(accessToken, user); + } + + @Override + public boolean checkAuthCode(String authCode) { + if(StringUtils.isEmpty(authCode)) { + return false; + } + return redisTemplate.hasKey(getCodeKey(authCode)); + } + + @Override + public OpenUser getUserByAuthCode(String authCode) { + String json = redisTemplate.opsForValue().get(getCodeKey(authCode)); + if(StringUtils.isEmpty(json)) { + return null; + } + return JSON.parseObject(json, UserInfo.class); + } + + @Override + public OpenUser getUserByAccessToken(String accessToken) { + String json = redisTemplate.opsForValue().get(getAccessTokenKey(accessToken)); + if(StringUtils.isEmpty(json)) { + return null; + } + return JSON.parseObject(json, UserInfo.class); + } + + @Override + public Map getParam(OpenUser user) { + Map map = new HashMap<>(); + map.put("username", user.getUsername()); + return map; + } + + @Override + public OpenUser login(HttpServletRequest request) throws LoginErrorException { + // 这里应该先检查用户有没有登录,如果登录直接返回openUser + + String username = request.getParameter("username"); + String password = request.getParameter("password"); + if(StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) { + throw new LoginErrorException("用户名密码不能为空"); + } + + Query query = new Query(); + query.eq("username", username) + .eq("password", password); + UserInfo userInfo = userInfoMapper.getByQuery(query); + + if(userInfo == null) { + throw new LoginErrorException("用户名密码不正确"); + } + + return userInfo; + } + +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ServiceImpl.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ServiceImpl.java new file mode 100644 index 00000000..7fdc8674 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/auth/impl/OAuth2ServiceImpl.java @@ -0,0 +1,284 @@ +package com.gitee.sop.sopauth.auth.impl; + +import com.gitee.sop.sopauth.auth.AppIdManager; +import com.gitee.sop.sopauth.auth.FetchTokenParam; +import com.gitee.sop.sopauth.auth.FetchTokenResult; +import com.gitee.sop.sopauth.auth.OAuth2Config; +import com.gitee.sop.sopauth.auth.OAuth2Manager; +import com.gitee.sop.sopauth.auth.OAuth2Service; +import com.gitee.sop.sopauth.auth.OpenUser; +import com.gitee.sop.sopauth.auth.RefreshToken; +import com.gitee.sop.sopauth.auth.TokenPair; +import com.gitee.sop.sopauth.auth.exception.LoginErrorException; +import lombok.extern.slf4j.Slf4j; +import org.apache.oltu.oauth2.as.issuer.MD5Generator; +import org.apache.oltu.oauth2.as.issuer.OAuthIssuer; +import org.apache.oltu.oauth2.as.issuer.OAuthIssuerImpl; +import org.apache.oltu.oauth2.as.request.OAuthAuthzRequest; +import org.apache.oltu.oauth2.as.response.OAuthASResponse; +import org.apache.oltu.oauth2.as.response.OAuthASResponse.OAuthTokenResponseBuilder; +import org.apache.oltu.oauth2.common.OAuth; +import org.apache.oltu.oauth2.common.error.OAuthError; +import org.apache.oltu.oauth2.common.exception.OAuthProblemException; +import org.apache.oltu.oauth2.common.exception.OAuthSystemException; +import org.apache.oltu.oauth2.common.message.OAuthResponse; +import org.apache.oltu.oauth2.common.message.types.GrantType; +import org.apache.oltu.oauth2.common.message.types.ResponseType; +import org.apache.oltu.oauth2.common.utils.OAuthUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +/** + * oauth2服务端默认实现 + * + * @author tanghc + */ +@Service +@Slf4j +public class OAuth2ServiceImpl implements OAuth2Service { + + + private static final String TOKEN_TYPE = "Bearer"; + + private OAuthIssuer oauthIssuer = new OAuthIssuerImpl(new MD5Generator()); + + @Autowired + private OAuth2Manager oauth2Manager; + + @Autowired + private AppIdManager appIdManager; + + @Override + public OAuthResponse authorize(HttpServletRequest request, HttpServletResponse resp, OAuth2Config authConfig) + throws URISyntaxException, OAuthSystemException { + try { + // 构建OAuth 授权请求 + OAuthAuthzRequest oauthRequest = new OAuthAuthzRequest(request); + String clientId = oauthRequest.getClientId(); + // 检查传入的客户端id是否正确 + if (!appIdManager.isValidAppId(clientId)) { + return OAuthASResponse.errorResponse(HttpServletResponse.SC_BAD_REQUEST) + .setError(OAuthError.TokenResponse.INVALID_CLIENT) + .setErrorDescription(OAuthError.TokenResponse.INVALID_CLIENT).buildJSONMessage(); + } + + // 如果用户没有登录,跳转到登陆页面 + OpenUser user = null; + try { + user = oauth2Manager.login(request); + } catch (LoginErrorException e) { + log.error(e.getMessage(), e); + request.setAttribute("error", e.getMessage()); + try { + request.getRequestDispatcher(authConfig.getOauth2LoginUri()).forward(request, resp); + return null; + } catch (Exception e1) { + log.error(e1.getMessage(), e1); + throw new RuntimeException(e1); + } + } + + // 生成授权码 + String authorizationCode = null; + // responseType目前仅支持CODE,另外还有TOKEN + String responseType = oauthRequest.getParam(OAuth.OAUTH_RESPONSE_TYPE); + if (responseType.equals(ResponseType.CODE.toString())) { + OAuthIssuerImpl oauthIssuerImpl = new OAuthIssuerImpl(new MD5Generator()); + authorizationCode = oauthIssuerImpl.authorizationCode(); + oauth2Manager.addAuthCode(authorizationCode, user); + } + // 进行OAuth响应构建 + OAuthASResponse.OAuthAuthorizationResponseBuilder builder = OAuthASResponse.authorizationResponse(request, + HttpServletResponse.SC_FOUND); + // 设置授权码 + builder.setCode(authorizationCode); + // 得到到客户端重定向地址 + String redirectURI = oauthRequest.getParam(OAuth.OAUTH_REDIRECT_URI); + + // 构建响应 + return builder.location(redirectURI).buildQueryMessage(); + } catch (OAuthProblemException e) { + // 出错处理 + String redirectUri = e.getRedirectUri(); + if (OAuthUtils.isEmpty(redirectUri)) { + // 告诉客户端没有传入redirectUri直接报错 + String error = "OAuth redirectUri needs to be provided by client!!!"; + return OAuthASResponse.errorResponse(HttpServletResponse.SC_FOUND) + .error(OAuthProblemException.error(error)).location(redirectUri).buildQueryMessage(); + } else { + // 返回错误消息(如?error=) + return OAuthASResponse.errorResponse(HttpServletResponse.SC_FOUND).error(e).location(redirectUri) + .buildQueryMessage(); + } + + } + } + + + @Override + public FetchTokenResult accessToken(FetchTokenParam fetchTokenParam, OAuth2Config authConfig) { + try { + // 这里需要检查client_id和client_secret,但是已经通过网关接口访问进来了 + // 表示client_id和client_secret是合法的 + + // 检查验证类型,如果是第一次进来用code换取accessToken + String grant_type = fetchTokenParam.getGrant_type(); + if (GrantType.AUTHORIZATION_CODE.toString().equals(grant_type)) { + String authCode = fetchTokenParam.getCode(); + if (!oauth2Manager.checkAuthCode(authCode)) { + FetchTokenResult errorResult = new FetchTokenResult(); + errorResult.setError(OAuthError.CodeResponse.INVALID_REQUEST); + errorResult.setError_description("invalid request"); + return errorResult; + } + // 生成Access Token + OpenUser user = oauth2Manager.getUserByAuthCode(authCode); + if (user == null) { + throw OAuthProblemException.error("Can not found user by code."); + } + + TokenPair tokenPair = this.createNewToken(user); + + oauth2Manager.addAccessToken(tokenPair.getAccessToken(), tokenPair.getRefreshToken(), user); + + // 生成OAuth响应 + return this.buildTokenResult(tokenPair, user); + } else if (GrantType.REFRESH_TOKEN.toString().equals(grant_type)) { + // 用refreshToken来刷新accessToken + String refreshToken = fetchTokenParam.getRefresh_token(); + if (StringUtils.isEmpty(refreshToken)) { + FetchTokenResult errorResult = new FetchTokenResult(); + errorResult.setError(OAuthError.ResourceResponse.EXPIRED_TOKEN); + errorResult.setError_description("expired token"); + return errorResult; + } + + RefreshToken refreshTokenObj = oauth2Manager.getRefreshToken(refreshToken); + if (refreshTokenObj == null) { + FetchTokenResult errorResult = new FetchTokenResult(); + errorResult.setError(OAuthError.ResourceResponse.INVALID_TOKEN); + errorResult.setError_description("invalid token"); + return errorResult; + } + + OpenUser user = refreshTokenObj.getOpenUser(); + // 老的token对 + TokenPair oldTokenPair = new TokenPair(); + oldTokenPair.setAccessToken(refreshTokenObj.getAccessToken()); + oldTokenPair.setRefreshToken(refreshToken); + // 创建一对新的accessToken和refreshToken + TokenPair newTokenPair = this.createRefreshToken(user, oldTokenPair); + + this.afterRefreshToken(oldTokenPair, newTokenPair, user); + + // 返回新的accessToken和refreshToken + return this.buildTokenResult(newTokenPair, user); + } else { + FetchTokenResult errorResult = new FetchTokenResult(); + errorResult.setError(OAuthError.TokenResponse.INVALID_GRANT); + errorResult.setError_description("invalid grant"); + return errorResult; + } + + } catch (OAuthProblemException e) { + log.error(e.getMessage(), e); + FetchTokenResult errorResult = new FetchTokenResult(); + errorResult.setError(e.getMessage()); + errorResult.setError_description("invalid grant"); + return errorResult; + } + } + + /** + * 刷新token后续操作 + * + * @param oldTokenPair 老的token + * @param newTokenPair 新的token + * @param user 用户 + */ + protected void afterRefreshToken(TokenPair oldTokenPair, TokenPair newTokenPair, OpenUser user) { + // 保存token + oauth2Manager.addAccessToken(newTokenPair.getAccessToken(), newTokenPair.getRefreshToken(), user); + + // 删除老的accessToken + oauth2Manager.removeAccessToken(oldTokenPair.getAccessToken()); + // 删除老的refreshToken + oauth2Manager.removeRefreshToken(oldTokenPair.getRefreshToken()); + } + + /** + * 创建新的token + * + * @param user + * @return 返回新token + */ + protected TokenPair createNewToken(OpenUser user) { + return this.createDefaultTokenPair(); + } + + /** + * 返回刷新后token + * + * @param user 用户 + * @param oldTokenPair 旧的token + * @return 返回新的token + */ + protected TokenPair createRefreshToken(OpenUser user, TokenPair oldTokenPair) { + return this.createDefaultTokenPair(); + } + + private TokenPair createDefaultTokenPair() { + TokenPair tokenPair = new TokenPair(); + try { + String accessToken = oauthIssuer.accessToken(); + String refreshToken = oauthIssuer.refreshToken(); + + tokenPair.setAccessToken(accessToken); + tokenPair.setRefreshToken(refreshToken); + + return tokenPair; + } catch (OAuthSystemException e) { + throw new RuntimeException(e); + } + } + + private FetchTokenResult buildTokenResult(TokenPair tokenPair, OpenUser user) { + OAuth2Config auth2Config = OAuth2Config.getInstance(); + FetchTokenResult fetchTokenResult = new FetchTokenResult(); + fetchTokenResult.setApp_auth_token(tokenPair.getAccessToken()); + fetchTokenResult.setApp_refresh_token(tokenPair.getRefreshToken()); + fetchTokenResult.setUser_id(user.getUserId()); + fetchTokenResult.setExpires_in(auth2Config.getAccessTokenExpiresIn()); + fetchTokenResult.setRe_expires_in(auth2Config.getRefreshTokenExpiresIn()); + return fetchTokenResult; + } + + private OAuthResponse buildAccessTokenResponse(TokenPair tokenPair, long expiresIn, OpenUser user) throws OAuthSystemException { + OAuthTokenResponseBuilder resp = OAuthASResponse.tokenResponse(HttpServletResponse.SC_OK); + + Map param = oauth2Manager.getParam(user); + if (param != null) { + Set> entrySet = param.entrySet(); + for (Entry entry : entrySet) { + resp.setParam(entry.getKey(), entry.getValue()); + } + } + + return resp + .setAccessToken(tokenPair.getAccessToken()) + .setRefreshToken(tokenPair.getRefreshToken()) + .setTokenType(TOKEN_TYPE) + .setExpiresIn(String.valueOf(expiresIn)) + .buildJSONMessage(); + + } + +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/config/OpenServiceConfig.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/config/OpenServiceConfig.java new file mode 100644 index 00000000..94298534 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/config/OpenServiceConfig.java @@ -0,0 +1,20 @@ +package com.gitee.sop.sopauth.config; + +import com.gitee.sop.servercommon.bean.ServiceConfig; +import com.gitee.sop.servercommon.configuration.AlipayServiceConfiguration; +import org.springframework.context.annotation.Configuration; + +/** + * 使用支付宝开放平台功能 + * + * @author tanghc + */ +@Configuration +public class OpenServiceConfig extends AlipayServiceConfiguration { + + + static { + ServiceConfig.getInstance().getI18nModules().add("i18n/isp/goods_error"); + } + +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/controller/CallbackController.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/controller/CallbackController.java new file mode 100644 index 00000000..02018d43 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/controller/CallbackController.java @@ -0,0 +1,54 @@ +package com.gitee.sop.sopauth.controller; + +import com.gitee.sop.sdk.client.OpenClient; +import com.gitee.sop.sdk.model.OpenAuthTokenAppModel; +import com.gitee.sop.sdk.request.OpenAuthTokenAppRequest; +import com.gitee.sop.sdk.response.OpenAuthTokenAppResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * @author tanghc + */ +@Controller +@Slf4j +public class CallbackController { + String url = "http://localhost:8081/api"; // zuul + String appId = "2019032617262200001"; + // 支付宝私钥 + String privateKey = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXJv1pQFqWNA/++OYEV7WYXwexZK/J8LY1OWlP9X0T6wHFOvxNKRvMkJ5544SbgsJpVcvRDPrcxmhPbi/sAhdO4x2PiPKIz9Yni2OtYCCeaiE056B+e1O2jXoLeXbfi9fPivJZkxH/tb4xfLkH3bA8ZAQnQsoXA0SguykMRZntF0TndUfvDrLqwhlR8r5iRdZLB6F8o8qXH6UPDfNEnf/K8wX5T4EB1b8x8QJ7Ua4GcIUqeUxGHdQpzNbJdaQvoi06lgccmL+PHzminkFYON7alj1CjDN833j7QMHdPtS9l7B67fOU/p2LAAkPMtoVBfxQt9aFj7B8rEhGCz02iJIBAgMBAAECggEARqOuIpY0v6WtJBfmR3lGIOOokLrhfJrGTLF8CiZMQha+SRJ7/wOLPlsH9SbjPlopyViTXCuYwbzn2tdABigkBHYXxpDV6CJZjzmRZ+FY3S/0POlTFElGojYUJ3CooWiVfyUMhdg5vSuOq0oCny53woFrf32zPHYGiKdvU5Djku1onbDU0Lw8w+5tguuEZ76kZ/lUcccGy5978FFmYpzY/65RHCpvLiLqYyWTtaNT1aQ/9pw4jX9HO9NfdJ9gYFK8r/2f36ZE4hxluAfeOXQfRC/WhPmiw/ReUhxPznG/WgKaa/OaRtAx3inbQ+JuCND7uuKeRe4osP2jLPHPP6AUwQKBgQDUNu3BkLoKaimjGOjCTAwtp71g1oo+k5/uEInAo7lyEwpV0EuUMwLA/HCqUgR4K9pyYV+Oyb8d6f0+Hz0BMD92I2pqlXrD7xV2WzDvyXM3s63NvorRooKcyfd9i6ccMjAyTR2qfLkxv0hlbBbsPHz4BbU63xhTJp3Ghi0/ey/1HQKBgQC2VsgqC6ykfSidZUNLmQZe3J0p/Qf9VLkfrQ+xaHapOs6AzDU2H2osuysqXTLJHsGfrwVaTs00ER2z8ljTJPBUtNtOLrwNRlvgdnzyVAKHfOgDBGwJgiwpeE9voB1oAV/mXqSaUWNnuwlOIhvQEBwekqNyWvhLqC7nCAIhj3yvNQKBgQCqYbeec56LAhWP903Zwcj9VvG7sESqXUhIkUqoOkuIBTWFFIm54QLTA1tJxDQGb98heoCIWf5x/A3xNI98RsqNBX5JON6qNWjb7/dobitti3t99v/ptDp9u8JTMC7penoryLKK0Ty3bkan95Kn9SC42YxaSghzqkt+uvfVQgiNGQKBgGxU6P2aDAt6VNwWosHSe+d2WWXt8IZBhO9d6dn0f7ORvcjmCqNKTNGgrkewMZEuVcliueJquR47IROdY8qmwqcBAN7Vg2K7r7CPlTKAWTRYMJxCT1Hi5gwJb+CZF3+IeYqsJk2NF2s0w5WJTE70k1BSvQsfIzAIDz2yE1oPHvwVAoGAA6e+xQkVH4fMEph55RJIZ5goI4Y76BSvt2N5OKZKd4HtaV+eIhM3SDsVYRLIm9ZquJHMiZQGyUGnsvrKL6AAVNK7eQZCRDk9KQz+0GKOGqku0nOZjUbAu6A2/vtXAaAuFSFx1rUQVVjFulLexkXR3KcztL1Qu2k5pB6Si0K/uwQ="; + + OpenClient openClient = new OpenClient(url, appId, privateKey); + + /** + * 模拟开发者回调,这里需要开发者自己实现.通过code换取token + * @return + */ + @GetMapping("oauth2callback") + @ResponseBody + public String callback(HttpServletRequest servletRequest, HttpServletResponse servletResponse) { + servletResponse.setCharacterEncoding("UTF-8"); + String app_id = servletRequest.getParameter("app_id"); + String code = servletRequest.getParameter("code"); + + OpenAuthTokenAppRequest request = new OpenAuthTokenAppRequest(); + OpenAuthTokenAppModel model = new OpenAuthTokenAppModel(); + model.setCode(code); + model.setGrant_type("authorization_code"); + request.setBizModel(model); + + // 根据code获取token + OpenAuthTokenAppResponse response = openClient.execute(request); + if (response.isSuccess()) { + // 成功拿到token,开发者在这里保存token信息 + // 后续使用token进行接口访问 + log.info("授权成功,body:{}", response.getBody()); + } + return response.getBody(); + } +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/controller/OAuth2Controller.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/controller/OAuth2Controller.java new file mode 100644 index 00000000..062d593a --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/controller/OAuth2Controller.java @@ -0,0 +1,85 @@ +package com.gitee.sop.sopauth.controller; + +import com.gitee.sop.servercommon.annotation.ApiMapping; +import com.gitee.sop.sopauth.auth.FetchTokenParam; +import com.gitee.sop.sopauth.auth.FetchTokenResult; +import com.gitee.sop.sopauth.auth.OAuth2Config; +import com.gitee.sop.sopauth.auth.OAuth2Service; +import org.apache.oltu.oauth2.common.exception.OAuthSystemException; +import org.apache.oltu.oauth2.common.message.OAuthResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * 授权认证 + */ +@Controller +@RequestMapping("oauth2") +public class OAuth2Controller { + + @Autowired + private OAuth2Service oAuth2Service; + + // 第一步:授权URL拼装 + // https://localhost:8087/oauth2/appToAppAuth?app_id=2019032617262200001&redirect_uri=http%3a%2f%2flocalhost%3a8087%2foauth2callback + @GetMapping("appToAppAuth") + public String appToAppAuth(HttpServletRequest request, ModelMap modelMap) { + String app_id = request.getParameter("app_id"); + String redirect_uri = request.getParameter("redirect_uri"); + modelMap.put("response_type", "code"); + modelMap.put("client_id", app_id); + modelMap.put("redirect_uri", redirect_uri); + return "oauth2login"; + } + + /** + * 第二步:点击登录,到这里拿code + * oauth2认证获取code + * @param request + * @param resp + * @return 返回code + * @throws URISyntaxException + * @throws OAuthSystemException + */ + @RequestMapping("authorize") + public Object authorize(HttpServletRequest request, HttpServletResponse resp) throws URISyntaxException, OAuthSystemException { + OAuthResponse response = oAuth2Service.authorize(request, resp, OAuth2Config.getInstance()); + if(response == null) { + return null; + } + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(new URI(response.getLocationUri())); + return new ResponseEntity(headers, HttpStatus.valueOf(response.getResponseStatus())); + } + + + /** + * 第三步,通过code获取token + * 或者,通过refresh_token换取token + * @param param + * @return + */ + @ApiMapping("open.auth.token.app") + @ResponseBody + public FetchTokenResult fetchToken(FetchTokenParam param) { + FetchTokenResult fetchTokenResult = oAuth2Service.accessToken(param, OAuth2Config.getInstance()); + return fetchTokenResult; + } + + @RequestMapping("login") + public String oauth2login() { + return "oauth2login"; + } +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/entity/IsvInfo.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/entity/IsvInfo.java new file mode 100644 index 00000000..64361500 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/entity/IsvInfo.java @@ -0,0 +1,32 @@ +package com.gitee.sop.sopauth.entity; + +import lombok.Data; + +import javax.persistence.Column; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + + +/** + * 表名:isv_info + * 备注:isv信息表 + * + * @author tanghc + */ +@Table(name = "isv_info") +@Data +public class IsvInfo { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + /** 数据库字段:id */ + private Long id; + + /** appKey, 数据库字段:app_key */ + private String appKey; + + /** 1启用,2禁用, 数据库字段:status */ + private Byte status; +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/entity/UserInfo.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/entity/UserInfo.java new file mode 100644 index 00000000..e4eaa0a4 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/entity/UserInfo.java @@ -0,0 +1,48 @@ +package com.gitee.sop.sopauth.entity; + +import com.gitee.sop.sopauth.auth.OpenUser; +import lombok.Data; + +import javax.persistence.Column; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.Date; + + +/** + * 表名:user_info + * 备注:用户信息表 + * + * @author tanghc + */ +@Table(name = "user_info") +@Data +public class UserInfo implements OpenUser { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + /** 数据库字段:id */ + private Long id; + + /** 用户名, 数据库字段:username */ + private String username; + + /** 密码, 数据库字段:password */ + private String password; + + /** 昵称, 数据库字段:nickname */ + private String nickname; + + /** 数据库字段:gmt_create */ + private Date gmtCreate; + + /** 数据库字段:gmt_modified */ + private Date gmtModified; + + @Override + public String getUserId() { + return String.valueOf(id); + } +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/mapper/IsvInfoMapper.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/mapper/IsvInfoMapper.java new file mode 100644 index 00000000..64687a97 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/mapper/IsvInfoMapper.java @@ -0,0 +1,11 @@ +package com.gitee.sop.sopauth.mapper; + +import com.gitee.fastmybatis.core.mapper.CrudMapper; +import com.gitee.sop.sopauth.entity.IsvInfo; + + +/** + * @author tanghc + */ +public interface IsvInfoMapper extends CrudMapper { +} diff --git a/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/mapper/UserInfoMapper.java b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/mapper/UserInfoMapper.java new file mode 100644 index 00000000..0c1c8219 --- /dev/null +++ b/sop-example/sop-auth/src/main/java/com/gitee/sop/sopauth/mapper/UserInfoMapper.java @@ -0,0 +1,11 @@ +package com.gitee.sop.sopauth.mapper; + +import com.gitee.fastmybatis.core.mapper.CrudMapper; +import com.gitee.sop.sopauth.entity.UserInfo; + + +/** + * @author tanghc + */ +public interface UserInfoMapper extends CrudMapper { +} diff --git a/sop-example/sop-auth/src/main/resources/application-dev.yml b/sop-example/sop-auth/src/main/resources/application-dev.yml new file mode 100644 index 00000000..636aaf6e --- /dev/null +++ b/sop-example/sop-auth/src/main/resources/application-dev.yml @@ -0,0 +1,35 @@ + +server: + port: 8087 + + +# 注册中心 +eureka: + port: 1111 + host: localhost + client: + serviceUrl: + defaultZone: http://${eureka.host}:${eureka.port}/eureka/ + +spring: + application: + name: sop-auth + + cloud: + zookeeper: + connect-string: localhost:2181 + + # 数据源 + datasource: + driver-class-name: com.mysql.jdbc.Driver + url: jdbc:mysql://localhost:3306/sop?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull + username: root + password: root + + thymeleaf: + cache: false + +logging: + level: + com: + gitee: debug \ No newline at end of file diff --git a/sop-example/sop-auth/src/main/resources/application.yml b/sop-example/sop-auth/src/main/resources/application.yml new file mode 100644 index 00000000..caf4dfcd --- /dev/null +++ b/sop-example/sop-auth/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: dev \ No newline at end of file diff --git a/sop-example/sop-auth/src/main/resources/templates/oauth2login.html b/sop-example/sop-auth/src/main/resources/templates/oauth2login.html new file mode 100644 index 00000000..5c089835 --- /dev/null +++ b/sop-example/sop-auth/src/main/resources/templates/oauth2login.html @@ -0,0 +1,19 @@ + + + +用户登录 + + +
+
+ + + + 用户名:
+ 密码:
+ +
+ + \ No newline at end of file diff --git a/sop-example/sop-auth/src/test/java/com/gitee/sop/sopauth/RefreshTokenTest.java b/sop-example/sop-auth/src/test/java/com/gitee/sop/sopauth/RefreshTokenTest.java new file mode 100644 index 00000000..199f50b5 --- /dev/null +++ b/sop-example/sop-auth/src/test/java/com/gitee/sop/sopauth/RefreshTokenTest.java @@ -0,0 +1,40 @@ +package com.gitee.sop.sopauth; + +import com.gitee.sop.sdk.client.OpenClient; +import com.gitee.sop.sdk.model.OpenAuthTokenAppModel; +import com.gitee.sop.sdk.request.OpenAuthTokenAppRequest; +import com.gitee.sop.sdk.response.OpenAuthTokenAppResponse; +import junit.framework.TestCase; +import lombok.extern.slf4j.Slf4j; + +/** + * @author tanghc + */ +@Slf4j +public class RefreshTokenTest extends TestCase { + String url = "http://localhost:8081/api"; // zuul + String appId = "2019032617262200001"; + // 支付宝私钥 + String privateKey = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXJv1pQFqWNA/++OYEV7WYXwexZK/J8LY1OWlP9X0T6wHFOvxNKRvMkJ5544SbgsJpVcvRDPrcxmhPbi/sAhdO4x2PiPKIz9Yni2OtYCCeaiE056B+e1O2jXoLeXbfi9fPivJZkxH/tb4xfLkH3bA8ZAQnQsoXA0SguykMRZntF0TndUfvDrLqwhlR8r5iRdZLB6F8o8qXH6UPDfNEnf/K8wX5T4EB1b8x8QJ7Ua4GcIUqeUxGHdQpzNbJdaQvoi06lgccmL+PHzminkFYON7alj1CjDN833j7QMHdPtS9l7B67fOU/p2LAAkPMtoVBfxQt9aFj7B8rEhGCz02iJIBAgMBAAECggEARqOuIpY0v6WtJBfmR3lGIOOokLrhfJrGTLF8CiZMQha+SRJ7/wOLPlsH9SbjPlopyViTXCuYwbzn2tdABigkBHYXxpDV6CJZjzmRZ+FY3S/0POlTFElGojYUJ3CooWiVfyUMhdg5vSuOq0oCny53woFrf32zPHYGiKdvU5Djku1onbDU0Lw8w+5tguuEZ76kZ/lUcccGy5978FFmYpzY/65RHCpvLiLqYyWTtaNT1aQ/9pw4jX9HO9NfdJ9gYFK8r/2f36ZE4hxluAfeOXQfRC/WhPmiw/ReUhxPznG/WgKaa/OaRtAx3inbQ+JuCND7uuKeRe4osP2jLPHPP6AUwQKBgQDUNu3BkLoKaimjGOjCTAwtp71g1oo+k5/uEInAo7lyEwpV0EuUMwLA/HCqUgR4K9pyYV+Oyb8d6f0+Hz0BMD92I2pqlXrD7xV2WzDvyXM3s63NvorRooKcyfd9i6ccMjAyTR2qfLkxv0hlbBbsPHz4BbU63xhTJp3Ghi0/ey/1HQKBgQC2VsgqC6ykfSidZUNLmQZe3J0p/Qf9VLkfrQ+xaHapOs6AzDU2H2osuysqXTLJHsGfrwVaTs00ER2z8ljTJPBUtNtOLrwNRlvgdnzyVAKHfOgDBGwJgiwpeE9voB1oAV/mXqSaUWNnuwlOIhvQEBwekqNyWvhLqC7nCAIhj3yvNQKBgQCqYbeec56LAhWP903Zwcj9VvG7sESqXUhIkUqoOkuIBTWFFIm54QLTA1tJxDQGb98heoCIWf5x/A3xNI98RsqNBX5JON6qNWjb7/dobitti3t99v/ptDp9u8JTMC7penoryLKK0Ty3bkan95Kn9SC42YxaSghzqkt+uvfVQgiNGQKBgGxU6P2aDAt6VNwWosHSe+d2WWXt8IZBhO9d6dn0f7ORvcjmCqNKTNGgrkewMZEuVcliueJquR47IROdY8qmwqcBAN7Vg2K7r7CPlTKAWTRYMJxCT1Hi5gwJb+CZF3+IeYqsJk2NF2s0w5WJTE70k1BSvQsfIzAIDz2yE1oPHvwVAoGAA6e+xQkVH4fMEph55RJIZ5goI4Y76BSvt2N5OKZKd4HtaV+eIhM3SDsVYRLIm9ZquJHMiZQGyUGnsvrKL6AAVNK7eQZCRDk9KQz+0GKOGqku0nOZjUbAu6A2/vtXAaAuFSFx1rUQVVjFulLexkXR3KcztL1Qu2k5pB6Si0K/uwQ="; + + OpenClient openClient = new OpenClient(url, appId, privateKey); + + /** + * 根据refreshToken换取token + */ + public void testRefreshToken() { + OpenAuthTokenAppRequest request = new OpenAuthTokenAppRequest(); + OpenAuthTokenAppModel model = new OpenAuthTokenAppModel(); + model.setGrant_type("refresh_token"); + model.setRefresh_token("856faf8d77d3b985c1073557ce6ea724"); + request.setBizModel(model); + + OpenAuthTokenAppResponse response = openClient.execute(request); + if (response.isSuccess()) { + // 成功拿到token,开发者在这里保存token信息 + // 后续使用token进行接口访问 + log.info("换取token成功,body:{}", response.getBody()); + } + } + +} diff --git a/sop-example/sop-auth/src/test/java/com/gitee/sop/sopauth/SopAuthApplicationTests.java b/sop-example/sop-auth/src/test/java/com/gitee/sop/sopauth/SopAuthApplicationTests.java new file mode 100644 index 00000000..b5902b77 --- /dev/null +++ b/sop-example/sop-auth/src/test/java/com/gitee/sop/sopauth/SopAuthApplicationTests.java @@ -0,0 +1,16 @@ +package com.gitee.sop.sopauth; + +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 SopAuthApplicationTests { + + @Test + public void contextLoads() { + } + +} diff --git a/sop-example/sop-book/sop-book-web/pom.xml b/sop-example/sop-book/sop-book-web/pom.xml index aa01c9b5..868507db 100644 --- a/sop-example/sop-book/sop-book-web/pom.xml +++ b/sop-example/sop-book/sop-book-web/pom.xml @@ -23,7 +23,7 @@ com.gitee.sop sop-service-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT com.gitee.sop diff --git a/sop-example/sop-easyopen/pom.xml b/sop-example/sop-easyopen/pom.xml index 5151c186..9b07d62a 100644 --- a/sop-example/sop-easyopen/pom.xml +++ b/sop-example/sop-easyopen/pom.xml @@ -29,7 +29,7 @@ com.gitee.sop sop-service-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT org.springframework.cloud diff --git a/sop-example/sop-springmvc/pom.xml b/sop-example/sop-springmvc/pom.xml index fc4739a7..4479a7f0 100644 --- a/sop-example/sop-springmvc/pom.xml +++ b/sop-example/sop-springmvc/pom.xml @@ -20,7 +20,7 @@ com.gitee.sop sop-service-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT diff --git a/sop-example/sop-story/sop-story-web/pom.xml b/sop-example/sop-story/sop-story-web/pom.xml index b912c66e..3b9599bc 100644 --- a/sop-example/sop-story/sop-story-web/pom.xml +++ b/sop-example/sop-story/sop-story-web/pom.xml @@ -23,7 +23,7 @@ com.gitee.sop sop-service-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT com.gitee.sop diff --git a/sop-gateway/pom.xml b/sop-gateway/pom.xml index 49d1b6fd..fc932851 100644 --- a/sop-gateway/pom.xml +++ b/sop-gateway/pom.xml @@ -23,7 +23,7 @@ com.gitee.sop sop-gateway-common - 1.5.0-SNAPSHOT + 1.6.0-SNAPSHOT diff --git a/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/model/OpenAuthTokenAppModel.java b/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/model/OpenAuthTokenAppModel.java new file mode 100644 index 00000000..8fa9c55c --- /dev/null +++ b/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/model/OpenAuthTokenAppModel.java @@ -0,0 +1,28 @@ +package com.gitee.sop.sdk.model; + +import lombok.Data; + +/** + * Oauth2认证参数 + * @author tanghc + */ +@Data +public class OpenAuthTokenAppModel { + + /** + * 授权类型。
+ * 如果使用app_auth_code换取token,则为authorization_code, + * 如果使用refresh_token换取新的token,则为refresh_token + */ + private String grant_type; + + /** + * 授权码.与refresh_token二选一,用户对应用授权后得到,即第一步中开发者获取到的app_auth_code值 + */ + private String code; + + /** + * 刷新令牌.与code二选一,可为空,刷新令牌时使用 + */ + private String refresh_token; +} diff --git a/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/request/OpenAuthTokenAppRequest.java b/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/request/OpenAuthTokenAppRequest.java new file mode 100644 index 00000000..0afcfb0b --- /dev/null +++ b/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/request/OpenAuthTokenAppRequest.java @@ -0,0 +1,13 @@ +package com.gitee.sop.sdk.request; + +import com.gitee.sop.sdk.response.OpenAuthTokenAppResponse; + +/** + * @author tanghc + */ +public class OpenAuthTokenAppRequest extends BaseRequest { + @Override + protected String method() { + return "open.auth.token.app"; + } +} diff --git a/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/response/GetStoryResponse.java b/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/response/GetStoryResponse.java index 69c8b959..ff6d900b 100644 --- a/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/response/GetStoryResponse.java +++ b/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/response/GetStoryResponse.java @@ -1,10 +1,12 @@ package com.gitee.sop.sdk.response; import lombok.Data; +import lombok.EqualsAndHashCode; import java.util.Date; @Data +@EqualsAndHashCode(callSuper = false) public class GetStoryResponse extends BaseResponse { private Long id; private String name; diff --git a/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/response/OpenAuthTokenAppResponse.java b/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/response/OpenAuthTokenAppResponse.java new file mode 100644 index 00000000..eb248ccb --- /dev/null +++ b/sop-sdk/sdk-java/src/main/java/com/gitee/sop/sdk/response/OpenAuthTokenAppResponse.java @@ -0,0 +1,30 @@ +package com.gitee.sop.sdk.response; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * @author tanghc + */ +@Data +@EqualsAndHashCode(callSuper = false) +public class OpenAuthTokenAppResponse extends BaseResponse { + /** + * 授权令牌 + */ + private String app_auth_token; + + /** + * 用户id + */ + private String user_id; + + /** + * 刷新令牌时使用。 + * 刷新令牌后,我们会保证老的app_auth_token从刷新开始10分钟内可继续使用,请及时替换为最新token + */ + private String app_refresh_token; + + private String error; + private String error_description; +} diff --git a/sop-website/website-front/assets/image/auth.png b/sop-website/website-front/assets/image/auth.png new file mode 100644 index 0000000000000000000000000000000000000000..6965e7e99f9f5613e3189b4fdb68cd31e02ea264 GIT binary patch literal 79808 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 Z?2|dN$a(+NrETD!=6SuJa(=W3_+R4S{ulrN literal 0 HcmV?d00001 diff --git a/sop-website/website-front/pages/doc/auth.html b/sop-website/website-front/pages/doc/auth.html new file mode 100644 index 00000000..e3ef0fe5 --- /dev/null +++ b/sop-website/website-front/pages/doc/auth.html @@ -0,0 +1,288 @@ + + + + + 应用授权 + + + + + + + + + + + + + + + + + + +
+
+ +
+
+

第三方应用授权

+
+
+ 概述 +
+
+
+

1、用户对开发者进行应用授权后,开发者可以帮助用户完成相应的业务逻辑。

+

2、授权采用标准的OAuth 2.0流程。

+
+ +

授权流程

+ + +
+
+ 快速接入 +
+
+
+

第一步:应用授权URL拼装

+

拼接规则:

+

http://openauth.yourdomain.com/oauth2/appToAppAuth?app_id=2019032617262200001&redirect_uri=http%3a%2f%2flocalhost%3a8087%2foauth2callback

+

参数说明:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
参数参数名称类型必填描述范例
app_id开发者应用的AppIdString开发者应用的AppId2015101400446982
redirect_uri回调页面String参数需要UrlEncodehttp%3A%2F%2Fexample.com
+ +

第二步:获取code

+

授权成功后,客户端会跳转至开发者定义的回调页面(即redirect_uri参数对应的url),在回调页面请求中会带上当次授权的授权码code和开发者的app_id,示例如下:

+

http://devloper.domain.com/oauth2callback?app_id=2015101400446982&code=ca34ea491e7146cc87d25fca24c4cD11

+ +

第三步:使用code换取app_auth_token

+

接口名称:open.auth.token.app

+

开发者通过code可以换取app_auth_token、授权用户的userId。

+

注意:应用授权的code唯一,code使用一次后失效,有效期24小时; app_auth_token永久有效。

+

请求参数说明

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
参数参数名称类型必填描述范例
grant_type授权类型String如果使用code换取token,则为authorization_code,如果使用refresh_token换取新的token,则为refresh_tokenauthorization_code
code授权码String与refresh_token二选一,用户对应用授权后得到,即第一步中开发者获取到的code值bf67d8d5ed754af297f72cc482287X62
refresh_token刷新令牌String与code二选一,可为空,刷新令牌时使用201510BB0c409dd5758b4d939d4008a525463X62
+ +

接口请求SDK示例

+
+   @GetMapping("oauth2callback")
+    @ResponseBody
+    public String callback(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
+        String app_id = servletRequest.getParameter("app_id");
+        String code = servletRequest.getParameter("code");
+
+        OpenAuthTokenAppRequest request = new OpenAuthTokenAppRequest();
+        OpenAuthTokenAppModel model = new OpenAuthTokenAppModel();
+        model.setCode(code);
+        model.setGrant_type("authorization_code");
+        request.setBizModel(model);
+
+        // 根据code获取token
+        OpenAuthTokenAppResponse response = openClient.execute(request);
+        if (response.isSuccess()) {
+            // 成功拿到token,开发者在这里保存token信息
+            // 后续使用token进行接口访问
+            log.info("授权成功,body:{}", response.getBody());
+        }
+        return response.getBody();
+    }
+
+
+

同步响应参数说明

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
参数参数名称类型必填描述范例
app_auth_token用户授权令牌String通过该令牌来帮助用户发起请求,完成业务856faf8d77d3b985c1073557ce6ea724
user_id授权用户的IDString授权者id1
expires_in令牌有效期Long负值表示永久有效-1
re_expires_in刷新令牌有效期Long负值表示永久有效-3
app_refresh_token刷新令牌时使用String刷新令牌后,我们会保证老的app_auth_token从刷新开始10分钟内可继续使用,请及时替换为最新token88e68196d2359667f0dc8136e6c86803
+

同步响应结果示例

+
{
+    "open_auth_token_app_response": {
+        "code": "10000",
+        "msg": "Success",
+        "app_auth_token": "88e68196d2359667f0dc8136e6c86803",
+        "app_refresh_token": "856faf8d77d3b985c1073557ce6ea724",
+        "expires_in": -1,
+        "re_expires_in": -3,
+        "user_id": "1"
+    },
+    "sign": "xxx"
+}
+
+ +

刷新token

+

+OpenAuthTokenAppRequest request = new OpenAuthTokenAppRequest();
+OpenAuthTokenAppModel model = new OpenAuthTokenAppModel();
+model.setGrant_type("refresh_token");
+model.setRefresh_token("856faf8d77d3b985c1073557ce6ea724");
+request.setBizModel(model);
+
+OpenAuthTokenAppResponse response = openClient.execute(request);
+if (response.isSuccess()) {
+    // 成功拿到token,开发者在这里保存token信息
+    // 后续使用token进行接口访问
+    log.info("换取token成功,body:{}", response.getBody());
+}
+        
+
+
+ + + + + + + \ No newline at end of file diff --git a/sop-website/website-front/pages/doc/doc.html b/sop-website/website-front/pages/doc/doc.html index 9e491b3d..3d9534ee 100644 --- a/sop-website/website-front/pages/doc/doc.html +++ b/sop-website/website-front/pages/doc/doc.html @@ -222,7 +222,7 @@ String 否 40 - 暂未实现 + 详见应用授权概述 diff --git a/sop.sql b/sop.sql index eb94db0b..d3f07bfb 100644 --- a/sop.sql +++ b/sop.sql @@ -16,6 +16,7 @@ DROP TABLE IF EXISTS `isv_info`; DROP TABLE IF EXISTS `config_route_limit`; DROP TABLE IF EXISTS `config_route_base`; DROP TABLE IF EXISTS `admin_user_info`; +DROP TABLE IF EXISTS `user_info`; CREATE TABLE `admin_user_info` ( @@ -106,7 +107,16 @@ CREATE TABLE `perm_role_permission` ( ) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8 COMMENT='角色权限表'; - +CREATE TABLE `user_info` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(64) NOT NULL DEFAULT '' COMMENT '用户名', + `password` varchar(128) NOT NULL DEFAULT '' COMMENT '密码', + `nickname` varchar(64) NOT NULL DEFAULT '' COMMENT '昵称', + `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_unamepwd` (`username`,`password`) USING BTREE +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COMMENT='用户信息表'; SET FOREIGN_KEY_CHECKS = @PREVIOUS_FOREIGN_KEY_CHECKS; @@ -191,7 +201,12 @@ INSERT INTO `perm_role_permission` (`id`, `role_code`, `route_id`, `gmt_create`, ALTER TABLE `perm_role_permission` ENABLE KEYS; UNLOCK TABLES; - +LOCK TABLES `user_info` WRITE; +ALTER TABLE `user_info` DISABLE KEYS; +INSERT INTO `user_info` (`id`, `username`, `password`, `nickname`, `gmt_create`, `gmt_modified`) VALUES + (1,'zhangsan','123456','张三','2019-04-27 08:32:57','2019-04-27 08:32:57'); +ALTER TABLE `user_info` ENABLE KEYS; +UNLOCK TABLES; SET FOREIGN_KEY_CHECKS = @PREVIOUS_FOREIGN_KEY_CHECKS;