diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..d804082a2d2e700710ae840d8712a2ffe23ff762 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +version: '3' +services: + nacos: + container_name: nacos + image: nacos/nacos-server:latest + restart: always + environment: + PREFER_HOST_MODE: hostname + MODE: standalone + ports: + - '8848:8848' + redis: + container_name: redis + image: redis:latest + restart: always + expose: + - 6379 + rabbitmq: + container_name: rabbitmq + image: rabbitmq:3-management + restart: always + expose: + - 5672 + ports: + - '15672:15672' + gateway: + container_name: gateway + image: seqdata-cloud-gateway + depends_on: + - nacos + ports: + - '8080:8080' + environment: + - server.port=8080 + - spring.cloud.nacos.config.server-addr=nacos:8848 + - spring.cloud.nacos.discovery.server-addr=nacos:8848 + - security.oauth2.resource.token-info-uri=http://authz/oauth/check_token + authz: + container_name: authz + image: seqdata-cloud-authz + depends_on: + - nacos + - redis + expose: + - 8080 + environment: + - server.port=8080 + - spring.redis.host=redis + - spring.cloud.nacos.config.server-addr=nacos:8848 + - spring.cloud.nacos.discovery.server-addr=nacos:8848 + authc: + container_name: authc + image: seqdata-cloud-authc + depends_on: + - nacos + expose: + - 8080 + environment: + - server.port=8080 + - spring.cloud.nacos.config.server-addr=nacos:8848 + - spring.cloud.nacos.discovery.server-addr=nacos:8848 + - security.oauth2.resource.token-info-uri=http://authz/oauth/check_token diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..384f4aa60b8a2cd47e76f3ed5749c45725e292a9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,146 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.2.1.RELEASE + + cn.seqdata.cloud + seqdata-cloud-parent + 2.2.1-SNAPSHOT + pom + + 1.8 + Hoxton.SR1 + 2.9.2 + 2.2.1.RELEASE + + + seqdata-cloud-gateway + seqdata-cloud-authz + seqdata-cloud-authc + + + + org.projectlombok + lombok + 1.18.24 + compile + true + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + ${nacos.version} + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + ${nacos.version} + + + + + io.springfox + springfox-swagger2 + ${swagger.version} + + + io.springfox + springfox-swagger-ui + ${swagger.version} + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + com.spotify + docker-maven-plugin + 1.2.2 + + + docker-build + install + + build + + + + docker-tag + deploy + + tag + + + ${project.artifactId} + + swr.cn-north-1.myhuaweicloud.com/seqdata/${project.artifactId} + + + + + docker-push + deploy + + push + + + + swr.cn-north-1.myhuaweicloud.com/seqdata/${project.artifactId} + + + + + + ${project.artifactId} + openjdk:8-jdk-alpine + / + + ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime + echo 'Asia/Shanghai' > /etc/timezone + + ["java","-jar","/${project.build.finalName}.jar"] + + + + / + ${project.build.directory} + ${project.build.finalName}.jar + + + + http://docker.voltmao.com:2375 + + + + + diff --git a/seqdata-cloud-authc/pom.xml b/seqdata-cloud-authc/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..1cab73c76935c6c61f08c9a2ee883aadea41e24d --- /dev/null +++ b/seqdata-cloud-authc/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.2.1.RELEASE + + seqdata-cloud-authc + 2.2.1-SNAPSHOT + + + joda-time + joda-time + + + org.springframework.data + spring-data-jpa + + + cn.seqdata.cloud + seqdata-cloud-starter + ${project.version} + + + javax.persistence + javax.persistence-api + 2.2 + + + diff --git a/seqdata-cloud-authc/src/main/docker/Dockerfile b/seqdata-cloud-authc/src/main/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..61caeee2a4d2277e549c4a544182cff4e83e38a4 --- /dev/null +++ b/seqdata-cloud-authc/src/main/docker/Dockerfile @@ -0,0 +1,6 @@ +FROM openjdk:8-jdk-alpine +VOLUME /tmp +ADD seqdata-cloud-authc-2.2.1-SNAPSHOT.jar myapp.jar +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo '$TZ' > /etc/timezone +ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","-Dspring.application.name=authc","/myapp.jar"] \ No newline at end of file diff --git a/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/AuthcServerApplication.java b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/AuthcServerApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..d0ad91eb1c0b3f5f0aaaee0a3167281555c76f46 --- /dev/null +++ b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/AuthcServerApplication.java @@ -0,0 +1,21 @@ +package cn.seqdata.oauth2; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +import cn.seqdata.syslog.EnableSyslog; + +/** + * Author: jrxian + * Date: 2020-01-20 00:37 + */ +@SpringBootApplication +@EnableDiscoveryClient +@EnableSyslog +public class AuthcServerApplication { + + public static void main(String[] args) { + new SpringApplication(AuthcServerApplication.class).run(args); + } +} diff --git a/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/JavaTimeController.java b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/JavaTimeController.java new file mode 100644 index 0000000000000000000000000000000000000000..ab7629e33b6edb470e02713309aa4a6574f411d3 --- /dev/null +++ b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/JavaTimeController.java @@ -0,0 +1,49 @@ +package cn.seqdata.oauth2.controller; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.security.PermitAll; + +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.bind.annotation.RestController; + +/** + * @author jrxian + * @date 2020/11/28 19:42 + */ +@PermitAll +@RestController +@RequestMapping("/javatime") +public class JavaTimeController { + + @GetMapping + public Map jdk8() { + Map values = new HashMap<>(); + values.put("date", LocalDate.now()); + values.put("time", LocalTime.now()); + values.put("instant", Instant.now()); + values.put("localdatetime", LocalDateTime.now()); + return values; + } + + @GetMapping("/date") + public LocalDate date(@RequestParam(required = false) LocalDate key) { + return key; + } + + @GetMapping("/time") + public LocalTime time(@RequestParam(required = false) LocalTime key) { + return key; + } + + @GetMapping("/localdatetime") + public LocalDateTime localdatetime(@RequestParam(required = false) LocalDateTime key) { + return key; + } +} diff --git a/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/JodaController.java b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/JodaController.java new file mode 100644 index 0000000000000000000000000000000000000000..08836cc64ff4c82e45ed6505d2fc823089b44d4f --- /dev/null +++ b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/JodaController.java @@ -0,0 +1,41 @@ +package cn.seqdata.oauth2.controller; + +import javax.annotation.security.PermitAll; + +import org.joda.time.DateTime; +import org.joda.time.LocalDate; +import org.joda.time.LocalDateTime; +import org.joda.time.LocalTime; +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.bind.annotation.RestController; + +/** + * @author jrxian + * @date 2020-08-20 16:42 + */ +@PermitAll +@RestController +@RequestMapping("/joda") +public class JodaController { + @GetMapping("/date") + public LocalDate date(@RequestParam(required = false) LocalDate key) { + return key; + } + + @GetMapping("/time") + public LocalTime time(@RequestParam(required = false) LocalTime key) { + return key; + } + + @GetMapping("/datetime") + public DateTime datetime(@RequestParam(required = false) DateTime key) { + return key; + } + + @GetMapping("/localdatetime") + public LocalDateTime localdatetime(@RequestParam(required = false) LocalDateTime key) { + return key; + } +} diff --git a/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/SpringDataController.java b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/SpringDataController.java new file mode 100644 index 0000000000000000000000000000000000000000..9a576e5dc40ebbb6d71c0377de932f68b8e3259e --- /dev/null +++ b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/SpringDataController.java @@ -0,0 +1,30 @@ +package cn.seqdata.oauth2.controller; + +import javax.annotation.security.PermitAll; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author jrxian + * @date 2020-08-20 16:43 + */ +@PermitAll +@RestController +@RequestMapping("/spring/data") +public class SpringDataController { + @GetMapping("/sort") + public Sort sort(String key, @SortDefault("id") Sort sort) { + return sort; + } + + @GetMapping("/pageable") + public Pageable pageable(String key, @PageableDefault(sort = "id") Pageable pageable) { + return pageable; + } +} diff --git a/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/SyslogController.java b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/SyslogController.java new file mode 100644 index 0000000000000000000000000000000000000000..f2c49216fd349c322d436bfe3f7e152d30f6b07b --- /dev/null +++ b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/SyslogController.java @@ -0,0 +1,33 @@ +package cn.seqdata.oauth2.controller; + +import javax.annotation.security.PermitAll; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.web.bind.annotation.*; + +import cn.seqdata.oauth2.client.entity.NamedObject; +import cn.seqdata.syslog.Syslog; + +/** + * @author jrxian + * @date 2020/12/12 15:50 + */ +@Api("系统日志") +@PermitAll +@RestController +@RequestMapping("/syslog") +public class SyslogController { + + @ApiOperation("获取数据") + @Syslog(value = "用户`#{details['name']}`登录#{errorCode==null?'成功':'失败'}") + @GetMapping("/{id}") + public long get(@PathVariable("id") long id) { + return id; + } + + @ApiOperation("保存数据") + @PutMapping + public void put(NamedObject object) { + } +} diff --git a/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/UserInfoController.java b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/UserInfoController.java new file mode 100644 index 0000000000000000000000000000000000000000..c9e5c26bd820b71963d1495f3bf51a24e1c60d5e --- /dev/null +++ b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/controller/UserInfoController.java @@ -0,0 +1,32 @@ +package cn.seqdata.oauth2.controller; + +import java.security.Principal; + +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Author: jrxian + * Date: 2020-01-27 17:16 + */ +@RestController +@RequestMapping("/current") +public class UserInfoController { + + @ApiOperation("通行证") + @GetMapping("/principal") + @PreAuthorize("isAuthenticated()") + public Principal principal(Principal principal) { + return principal; + } + + @ApiOperation("用于测试行级权限是否生效,当输入的id是用户id时通过验证,否则报403") + @GetMapping("/permission") + @PreAuthorize("hasPermission(#id, null, 'read')") + public long permission(long id) { + return id; + } +} diff --git a/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/security/EnhancePrincipalExtractor.java b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/security/EnhancePrincipalExtractor.java new file mode 100644 index 0000000000000000000000000000000000000000..cb4a2ddbd84fcc5b404de6e4f6a834e62d48af0c --- /dev/null +++ b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/security/EnhancePrincipalExtractor.java @@ -0,0 +1,27 @@ +package cn.seqdata.oauth2.security; + +import java.util.Map; + +import org.springframework.boot.autoconfigure.security.oauth2.resource.PrincipalExtractor; +import org.springframework.stereotype.Component; + +import cn.seqdata.oauth2.client.OAuth2Constants; + +/** + * Author: jrxian + * Date: 2020-02-14 08:47 + */ +@Component +public class EnhancePrincipalExtractor implements PrincipalExtractor, OAuth2Constants { + private static final String[] PRINCIPAL_KEYS = new String[]{USERNAME, MOBILE, EMAIL, ID}; + + @Override + public Object extractPrincipal(Map map) { + for(String key : PRINCIPAL_KEYS) { + if(map.containsKey(key)) { + return map.get(key); + } + } + return null; + } +} diff --git a/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/security/TestPermissionEvaluator.java b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/security/TestPermissionEvaluator.java new file mode 100644 index 0000000000000000000000000000000000000000..3a4d18712fb1d86537df3de67d5a818a354f224b --- /dev/null +++ b/seqdata-cloud-authc/src/main/java/cn/seqdata/oauth2/security/TestPermissionEvaluator.java @@ -0,0 +1,32 @@ +package cn.seqdata.oauth2.security; + +import java.util.Objects; + +import org.springframework.stereotype.Component; + +import cn.seqdata.oauth2.client.domain.AuthUser; +import cn.seqdata.oauth2.client.domain.RepoScope; +import cn.seqdata.oauth2.client.security.EntityPermissionEvaluator; + +/** + * Author: jrxian + * Date: 2020-02-14 20:48 + */ +@Component +public class TestPermissionEvaluator implements EntityPermissionEvaluator { + + @Override + public Class entityType() { + return Object.class; + } + + @Override + public long getId(Object entity) { + return 0; + } + + @Override + public boolean hasPermission(AuthUser authUser, long entityId, RepoScope scope) { + return Objects.nonNull(authUser.getUserId()) && authUser.getUserId() == entityId && RepoScope.read.equals(scope); + } +} diff --git a/seqdata-cloud-authc/src/main/resources/application.yml b/seqdata-cloud-authc/src/main/resources/application.yml new file mode 100644 index 0000000000000000000000000000000000000000..a5ec962200e1d5af0f7f78d8c7aba78b979ada97 --- /dev/null +++ b/seqdata-cloud-authc/src/main/resources/application.yml @@ -0,0 +1,24 @@ +spring: + application: + name: authc + profiles: + active: logging, jackson + security: + user: + name: authc + password: authc@123 + roles: anonymous +security: + oauth2: + resource: + prefer-token-info: false + token-info-uri: http://authz/oauth/check_token + user-info-uri: http://authz/user/info + permit-all: + - /type/** + - /code/** + - /joda/** + - /jdk8/** + client: + client-id: client + client-secret: secret diff --git a/seqdata-cloud-authz/pom.xml b/seqdata-cloud-authz/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..233259a0ee2afa5a8139b304a879282461dea9ec --- /dev/null +++ b/seqdata-cloud-authz/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + + cn.seqdata.cloud + seqdata-cloud-parent + 2.2.1-SNAPSHOT + + seqdata-cloud-authz + 认证服务器,提供oauth2认证,接入第三方认证(如微信、钉钉) + + + org.springframework.boot + spring-boot-starter-amqp + + + org.springframework.boot + spring-boot-starter-data-jdbc + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.security + spring-security-oauth2-client + + + org.springframework.security.oauth.boot + spring-security-oauth2-autoconfigure + + + + + + com.aliyun + aliyun-java-sdk-core + 4.5.2 + + + com.aliyun + aliyun-java-sdk-dysmsapi + 2.1.0 + + + + cn.seqdata.cloud + seqdata-cloud-wechat + ${project.version} + + + cn.seqdata.cloud + seqdata-cloud-oauth2 + ${project.version} + + + cn.seqdata.cloud + seqdata-cloud-syslog + ${project.version} + + + + com.microsoft.sqlserver + mssql-jdbc + runtime + + + mysql + mysql-connector-java + runtime + + + com.oracle.ojdbc + ojdbc8 + 19.3.0.0 + runtime + + + com.oracle.database.nls + orai18n + 19.3.0.0 + runtime + + + diff --git a/seqdata-cloud-authz/src/main/docker/Dockerfile b/seqdata-cloud-authz/src/main/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..e99c6341eccbaf20d512bfa282b83346b451cae3 --- /dev/null +++ b/seqdata-cloud-authz/src/main/docker/Dockerfile @@ -0,0 +1,6 @@ +FROM openjdk:8-jdk-alpine +VOLUME /tmp +ADD seqdata-cloud-authz-2.2.1-SNAPSHOT.jar myapp.jar +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo '$TZ' > /etc/timezone +ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","-Dspring.application.name=authz","/myapp.jar"] \ No newline at end of file diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AliyunProperties.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AliyunProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..4d4d7c95f21ad066efbb876ee15f33e10ff62acc --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AliyunProperties.java @@ -0,0 +1,15 @@ +package cn.seqdata.oauth2; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author jrxian + * @date 2020-06-21 19:02 + */ +@lombok.Getter +@lombok.Setter +@ConfigurationProperties("aliyun") +public class AliyunProperties { + private String accessKey; + private String accessSecret; +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AliyunSmsProperties.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AliyunSmsProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..9b371f1f97f734cdfc8cc26ae8f2d313ac950b15 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AliyunSmsProperties.java @@ -0,0 +1,17 @@ +package cn.seqdata.oauth2; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author jrxian + * @date 2020-06-21 19:01 + */ +@lombok.Getter +@lombok.Setter +@ConfigurationProperties("aliyun.sms") +public class AliyunSmsProperties { + private Boolean enabled; + private String regionId; + private String templateCode; + private String signName; +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthcServerConfiguration.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthcServerConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..9e47b1d27f1c8f94d75a5bf31628586b574e8dbe --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthcServerConfiguration.java @@ -0,0 +1,61 @@ +package cn.seqdata.oauth2; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationEventPublisher; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; +import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; +import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; +import org.springframework.security.web.access.channel.ChannelProcessingFilter; + +import cn.seqdata.oauth2.filter.AddressFilter; +import cn.seqdata.oauth2.filter.AddressProperties; +import cn.seqdata.oauth2.filter.PeriodFilter; +import cn.seqdata.oauth2.filter.PeriodProperties; + +/** + * Author: jrxian + * Date: 2019-09-29 23:09 + */ +@Configuration +@EnableResourceServer +@EnableGlobalMethodSecurity(prePostEnabled = true) +@EnableConfigurationProperties({AddressProperties.class, PeriodProperties.class}) +@lombok.RequiredArgsConstructor +public class AuthcServerConfiguration extends ResourceServerConfigurerAdapter { + private final AddressProperties remoteAddrProperties; + private final PeriodProperties periodProperties; + + @Override + public void configure(ResourceServerSecurityConfigurer resources) { + //禁用授权服务器中资源服务器的授权事件发布,会导致大量的内部登录被日志记录 + resources.eventPublisher(new AuthenticationEventPublisher() { + @Override + public void publishAuthenticationSuccess(Authentication authentication) { + } + + @Override + public void publishAuthenticationFailure(AuthenticationException exception, Authentication authentication) { + } + }); + } + + @Override + public void configure(HttpSecurity http) throws Exception { + http.cors(); + + http.addFilterAfter(new AddressFilter(remoteAddrProperties), ChannelProcessingFilter.class); + http.addFilterAfter(new PeriodFilter(periodProperties), ChannelProcessingFilter.class); + + http.authorizeRequests() + .antMatchers("/csrf", "/oauth2/**", "/rbac/**", "/perm/**" + , "/oauth/code/**", "/connect/nonce", "/connect/v2/nonce", "/password/**", "/wxapp/**") + .permitAll(); + + super.configure(http); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthorizationServerConfiguration.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthorizationServerConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..2f61f69c2897fe5c454b417045d166b94b6d37c2 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthorizationServerConfiguration.java @@ -0,0 +1,116 @@ +package cn.seqdata.oauth2; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.ObjectPostProcessor; +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.ClientDetailsService; +import org.springframework.security.oauth2.provider.CompositeTokenGranter; +import org.springframework.security.oauth2.provider.OAuth2RequestFactory; +import org.springframework.security.oauth2.provider.TokenGranter; +import org.springframework.security.oauth2.provider.client.ClientCredentialsTokenGranter; +import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices; +import org.springframework.security.oauth2.provider.code.AuthorizationCodeTokenGranter; +import org.springframework.security.oauth2.provider.implicit.ImplicitTokenGranter; +import org.springframework.security.oauth2.provider.refresh.RefreshTokenGranter; +import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; +import org.springframework.security.oauth2.provider.token.TokenEnhancer; +import org.springframework.security.oauth2.provider.token.TokenStore; + +import cn.seqdata.crypto.exchanger.KeyExchanger; +import cn.seqdata.oauth2.nonce.*; +import cn.seqdata.oauth2.repos.oauth.ClientDetailRepo; +import cn.seqdata.oauth2.service.JpaClientDetailsService; +import cn.seqdata.oauth2.service.JpaUserDetailsManager; +import lombok.AllArgsConstructor; + +/** + * Author: jrxian + * Date: 2019-10-02 19:11 + */ +@Configuration +@EnableAuthorizationServer +@AllArgsConstructor +public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter { + private final AuthenticationManager authenticationManager; + private final JpaUserDetailsManager userDetailsManager; + private final AuthorizationServerTokenServices tokenServices; + private final KeyExchanger keyExchanger; + private final TokenStore tokenStore; + private final TokenEnhancer tokenEnhancer; + private final ClientDetailRepo clientDetailRepo; + private final List nonceHandlers; + private final NonceUserService userService; + + @Override + public void configure(AuthorizationServerSecurityConfigurer security) { + security + .tokenKeyAccess("isAuthenticated()") + .checkTokenAccess("isAuthenticated()") + .addObjectPostProcessor(new ObjectPostProcessor() { + @Override + public O postProcess(O provider) { + provider.setForcePrincipalAsString(true); + return provider; + } + }); + } + + @Override + public void configure(ClientDetailsServiceConfigurer clients) throws Exception { + clients.withClientDetails(new JpaClientDetailsService(clientDetailRepo)); + } + + @Override + public void configure(AuthorizationServerEndpointsConfigurer endpoints) { + endpoints + .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST) + .authenticationManager(authenticationManager) + .userDetailsService(userDetailsManager) + .tokenServices(tokenServices) + .tokenStore(tokenStore) + .tokenEnhancer(tokenEnhancer) + .tokenGranter(tokenGranter(endpoints)); + } + + /** + * 重写 AuthorizationServerEndpointsConfigurer.createDefaultTokenServices,添加 NonceTokenGranter + */ + private TokenGranter tokenGranter(AuthorizationServerEndpointsConfigurer endpoints) { + ClientDetailsService clientDetails = endpoints.getClientDetailsService(); + AuthorizationServerTokenServices tokenServices = endpoints.getTokenServices(); + AuthorizationCodeServices authorizationCodeServices = endpoints.getAuthorizationCodeServices(); + OAuth2RequestFactory requestFactory = endpoints.getOAuth2RequestFactory(); + + List tokenGranters = new ArrayList<>(); + tokenGranters.add(new AuthorizationCodeTokenGranter(tokenServices, authorizationCodeServices, clientDetails, requestFactory)); + tokenGranters.add(new RefreshTokenGranter(tokenServices, clientDetails, requestFactory)); + tokenGranters.add(new ImplicitTokenGranter(tokenServices, clientDetails, requestFactory)); + tokenGranters.add(new ClientCredentialsTokenGranter(tokenServices, clientDetails, requestFactory)); + + //自定义的手机/邮箱验证码登录 + //!!!必须在用户名密码验证前面 + NonceAuthenticationProvider mobileNonceProvider = new NonceAuthenticationProvider(JpaUserDetailsManager::loadUserByMobile, userDetailsManager, nonceHandlers, userService); + tokenGranters.add(new NonceTokenGranter(mobileNonceProvider, tokenServices, clientDetails, requestFactory, OAuth2Constants.MOBILE)); + + NonceAuthenticationProvider emailNonceProvider = new NonceAuthenticationProvider(JpaUserDetailsManager::loadUserByEmail, userDetailsManager, nonceHandlers, userService); + tokenGranters.add(new NonceTokenGranter(emailNonceProvider, tokenServices, clientDetails, requestFactory, OAuth2Constants.EMAIL)); + + if(Objects.nonNull(authenticationManager)) { + //用户名密码验证,用authz.PasswordTokenGranter代替,需要把账号状态输出到前端 + tokenGranters.add(new PasswordTokenGranter(keyExchanger, authenticationManager, tokenServices, clientDetails, requestFactory)); + } + + return new CompositeTokenGranter(tokenGranters); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthzServerApplication.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthzServerApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..986a5d3d537f244f84e26b898ff93018b0a46be0 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthzServerApplication.java @@ -0,0 +1,23 @@ +package cn.seqdata.oauth2; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.openfeign.EnableFeignClients; + +import cn.seqdata.wxapp.WxAppFeignClient; + +/** + * Author: jrxian + * Date: 2019-10-02 19:11 + */ +@SpringBootApplication +//@EnableCaching +@EnableDiscoveryClient +@EnableFeignClients(clients = WxAppFeignClient.class) +public class AuthzServerApplication { + + public static void main(String[] args) { + SpringApplication.run(AuthzServerApplication.class, args); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthzServerConfiguration.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthzServerConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..4789a9177e480e242e58fc9b08007d88bba7f748 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/AuthzServerConfiguration.java @@ -0,0 +1,97 @@ +package cn.seqdata.oauth2; + +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.DelegatingPasswordEncoder; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.provider.ClientDetailsService; +import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; +import org.springframework.security.oauth2.provider.token.TokenEnhancer; +import org.springframework.security.oauth2.provider.token.TokenStore; + +import cn.seqdata.crypto.exchanger.KeyExchanger; +import cn.seqdata.crypto.exchanger.KeyExchangers; +import cn.seqdata.oauth2.policy.PasswordPolicy; +import cn.seqdata.oauth2.policy.checker.*; +import cn.seqdata.oauth2.repos.rbac.PasswordHistoryRepo; +import cn.seqdata.oauth2.repos.rbac.UserRepo; +import cn.seqdata.oauth2.service.DefaultTokenEnhancer; +import cn.seqdata.oauth2.service.DefaultTokenServices; +import cn.seqdata.oauth2.service.RedisTokenStore; +import cn.seqdata.oauth2.service.UserService; +import lombok.AllArgsConstructor; + +/** + * @author jrxian + * @date 2020-11-23 19:07 + */ +@Configuration +@AllArgsConstructor +public class AuthzServerConfiguration { + + @Bean + public KeyExchanger keyExchanger(PasswordPolicy policy, StringRedisTemplate redisTemplate) { + final String PRIVATE_KEY = "exchanger.privateKey"; + final String PUBLIC_KEY = "exchanger.publicKey"; + + KeyExchangers keyExchangeMethod = policy.getKeyExchangeMethod(); + KeyExchanger keyExchanger = keyExchangeMethod.supplier.get(); + + ValueOperations opsForValue = redisTemplate.opsForValue(); + + if(BooleanUtils.isTrue(redisTemplate.hasKey(PRIVATE_KEY)) && StringUtils.isNotBlank(opsForValue.get(PRIVATE_KEY))) { + keyExchanger.setPrivateKey(opsForValue.get(PRIVATE_KEY)); + keyExchanger.setPublicKey(opsForValue.get(PUBLIC_KEY)); + } else { + keyExchanger.genKeyPair(); + opsForValue.set(PRIVATE_KEY, keyExchanger.getPrivateKey()); + opsForValue.set(PUBLIC_KEY, keyExchanger.getPublicKey()); + } + + return keyExchanger; + } + + @Bean + public PasswordEncoder passwordEncoder() { + PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + if(encoder instanceof DelegatingPasswordEncoder) { + DelegatingPasswordEncoder delegating = (DelegatingPasswordEncoder) encoder; + delegating.setDefaultPasswordEncoderForMatches(NoOpPasswordEncoder.getInstance()); + } + return encoder; + } + + @Bean + public PasswordChecker passwordChecker(PasswordPolicy policy, PasswordEncoder encoder, PasswordHistoryRepo historyRepo) { + CompositePasswordChecker passwordChecker = new CompositePasswordChecker(); + passwordChecker.addChecker(new UsernameChecker()); + passwordChecker.addChecker(new TextChecker()); + passwordChecker.addChecker(new StrengthChecker(policy)); + passwordChecker.addChecker(new HistoryChecker(policy, encoder, historyRepo)); + return passwordChecker; + } + + @Bean + public TokenStore tokenStore(StringRedisTemplate redisTemplate, PasswordPolicy passwordPolicy) { + RedisTokenStore tokenStore = new RedisTokenStore(redisTemplate); + tokenStore.setMaximumSessions(passwordPolicy.getMaximumSessions()); + return tokenStore; + } + + @Bean + public TokenEnhancer tokenEnhancer(UserRepo userRepo, UserService userService) { + return new DefaultTokenEnhancer(userRepo, userService); + } + + @Bean + public AuthorizationServerTokenServices tokenServices(ClientDetailsService clientDetailsService, + TokenStore tokenStore, TokenEnhancer tokenEnhancer) { + return new DefaultTokenServices(clientDetailsService, tokenStore, tokenEnhancer); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/BizlogConfiguration.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/BizlogConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..75df22ffd354eb7507ecb9ae582b4fa56ebbf095 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/BizlogConfiguration.java @@ -0,0 +1,134 @@ +package cn.seqdata.oauth2; + +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.event.AbstractAuthenticationEvent; +import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent; +import org.springframework.security.authentication.event.AuthenticationSuccessEvent; +import org.springframework.security.authentication.event.LogoutSuccessEvent; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.util.CollectionUtils; +import com.fasterxml.jackson.databind.ObjectMapper; + +import cn.seqdata.syslog.*; + +/** + * @author jrxian + * @date 2020/12/13 13:24 + */ +@Configuration +@EnableSyslog +public class BizlogConfiguration { + @Value("${spring.application.name}") + private String appName; + + @Bean + public MessageConverter messageConverter(ObjectMapper objectMapper) { + return new Jackson2JsonMessageConverter(objectMapper); + } + + @Bean + @ConditionalOnProperty("spring.rabbitmq.host") + public RabbitSyslogConsumer rabbitSyslogConsumer(RabbitTemplate rabbitTemplate) { + return new RabbitSyslogConsumer(rabbitTemplate); + } + + @Bean + public ApplicationListener syslogAuthSuccessListener(CompositeSyslogConsumer consumer) { + return event -> { + if(isBuiltInUser(event.getAuthentication())) { + return; + } + + SyslogRecord.SyslogRecordBuilder builder = logBuilder("login", "登录", event); + consumer.accept(builder.build()); + }; + } + + @Bean + public ApplicationListener syslogAuthFailureListener(CompositeSyslogConsumer consumer) { + return event -> { + if(isBuiltInUser(event.getAuthentication())) { + return; + } + + AuthenticationException exception = event.getException(); + Class exceptionClass = exception.getClass(); + + SyslogRecord.SyslogRecordBuilder builder = logBuilder("login", "登录", event) + .errorCode(exceptionClass.getSimpleName()); + consumer.accept(builder.build()); + }; + } + + @Bean + public ApplicationListener syslogLogoutSuccessListener(CompositeSyslogConsumer consumer) { + return event -> { + if(isBuiltInUser(event.getAuthentication())) { + return; + } + + SyslogRecord.SyslogRecordBuilder builder = logBuilder("logout", "注销", event); + consumer.accept(builder.build()); + }; + } + + /** + * 系统内置用户,不记录登录日志 + */ + private boolean isBuiltInUser(Authentication authentication) { + //没有任何角色 + if(CollectionUtils.isEmpty(authentication.getAuthorities())) { + return true; + } + + //匿名用户 + if(authentication instanceof AnonymousAuthenticationToken) { + return true; + } + + //client登录 + if(authentication instanceof OAuth2Authentication) { + return ((OAuth2Authentication) authentication).isClientOnly(); + } + + return false; + } + + private SyslogRecord.SyslogRecordBuilder logBuilder(String eventId, String eventName, AbstractAuthenticationEvent event) { + return SyslogRecord.builder() + .projectId(appName) + .eventId(eventId) + .eventName(eventName) + .eventTime(event.getTimestamp()) + .forwardIp(DefaultSyslogResolver.forwardAddr()) + .sourceIp(DefaultSyslogResolver.remoteAddr()) + .clientId(clientId(event)) + .username(DefaultSyslogResolver.username(event.getAuthentication())); + } + + private String clientId(AbstractAuthenticationEvent event) { + String clientId = DefaultSyslogResolver.clientId(event.getAuthentication()); + if(StringUtils.isNotBlank(clientId)) { + return clientId; + } + Object details = event.getAuthentication() + .getDetails(); + if(details instanceof Map) { + return (String) ((Map) details).get("clientId"); + } + return null; + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/LockerConfiguration.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/LockerConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..5da5e5ca2ef8612682c1b53ccbdabed1838ac0bc --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/LockerConfiguration.java @@ -0,0 +1,60 @@ +package cn.seqdata.oauth2; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import org.joda.time.DateTime; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent; +import org.springframework.security.authentication.event.AuthenticationSuccessEvent; +import org.springframework.security.core.Authentication; + +import cn.seqdata.oauth2.policy.PasswordPolicy; +import cn.seqdata.oauth2.service.AccountLockService; + +/** + * @author jrxian + * @date 2020/12/13 23:44 + * 密码连续错误超过3次,锁定5分钟 + */ +@Configuration +@lombok.AllArgsConstructor +public class LockerConfiguration { + private final StringRedisTemplate redisTemplate; + private final PasswordPolicy passwordPolicy; + private final AccountLockService lockService; + + @Bean + public ApplicationListener lockAuthcSuccessListener() { + return event -> { + Authentication authentication = event.getAuthentication(); + String username = authentication.getName(); + redisTemplate.delete(lockName(username)); + lockService.unlock(username); + }; + } + + @Bean + public ApplicationListener lockAuthcBadCredentialsListener() { + return event -> { + Authentication authentication = event.getAuthentication(); + String username = authentication.getName(); + String lockName = lockName(username); + ValueOperations opsForValue = redisTemplate.opsForValue(); + Long hits = opsForValue.increment(lockName); + if(Objects.nonNull(hits) && hits > passwordPolicy.lockHits) { + redisTemplate.expire(lockName, passwordPolicy.lockExpire, TimeUnit.SECONDS); + DateTime occur = DateTime.now(); + lockService.lock(username, occur.plusMinutes(5)); + } + }; + } + + public static String lockName(String username) { + return "lock:" + username; + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/NonceConfiguration.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/NonceConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..94844433b466d2e7ed59dd1de036e8332db775ef --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/NonceConfiguration.java @@ -0,0 +1,74 @@ +package cn.seqdata.oauth2; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.mail.MailProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.AuthenticationException; +import cn.seqdata.oauth2.nonce.AliyunSmsService; +import cn.seqdata.oauth2.nonce.EmailNonceHandler; +import cn.seqdata.oauth2.nonce.MobileNonceHandler; +import cn.seqdata.oauth2.nonce.NonceHandler; +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; +import cn.seqdata.oauth2.service.UserService; + +/** + * Author: jrxian + * Date: 2020-04-07 10:17 + */ +@Configuration +@EnableConfigurationProperties({AliyunProperties.class, AliyunSmsProperties.class}) +public class NonceConfiguration { + + @Bean + @ConditionalOnProperty(value = "aliyun.sms.enabled") + public AliyunSmsService smsService(AliyunProperties properties, AliyunSmsProperties smsProperties) { + return new AliyunSmsService(properties, smsProperties); + } + + @Bean + @ConditionalOnBean(AliyunSmsService.class) + public NonceHandler mobileNonceService(UserAccountRepo accountRepo, UserService userService, + AliyunSmsService smsService, StringRedisTemplate redisTemplate) { + return new MobileNonceHandler(accountRepo, userService, smsService, redisTemplate); + } + + @Bean + @ConditionalOnProperty(value = "spring.mail.host") + public NonceHandler emailNonceService(UserAccountRepo accountRepo, JavaMailSender mailSender, + MailProperties mailProperties, StringRedisTemplate redisTemplate) { + return new EmailNonceHandler(accountRepo, mailSender, mailProperties, redisTemplate); + } + + @Bean + @ConditionalOnMissingBean(name = {"smsService", "emailNonceService"}) + public NonceHandler anonymousMobileNonceHandler() { + return new NonceHandler() { + @Override + public boolean support(String username) { + return true; + } + + @Override + public void nonce(String mobile, int expire, String clientId) { + throw new AuthenticationServiceException("未实现验证码登录功能"); + } + + @Override + public void nonceWithRedis(String mobile, int expire) { + throw new AuthenticationServiceException("未实现验证码登录功能"); + } + + @Override + public void check(String mobile, int nonce) throws AuthenticationException { + throw new AuthenticationServiceException("未实现验证码登录功能"); + } + }; + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/SwaggerConfiguration.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/SwaggerConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..e4e75b7fe777cd0cfc1f0c6a3598625fefab3fa1 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/SwaggerConfiguration.java @@ -0,0 +1,42 @@ +package cn.seqdata.oauth2; + +import java.util.Collections; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import springfox.documentation.builders.OAuthBuilder; +import springfox.documentation.builders.PathSelectors; +import springfox.documentation.service.AuthorizationScope; +import springfox.documentation.service.ResourceOwnerPasswordCredentialsGrant; +import springfox.documentation.service.SecurityReference; +import springfox.documentation.spi.DocumentationType; +import springfox.documentation.spi.service.contexts.SecurityContext; +import springfox.documentation.spring.web.plugins.Docket; +import springfox.documentation.swagger2.annotations.EnableSwagger2; + +/** + * Author: jrxian + * Date: 2020-02-04 01:36 + */ +@Configuration +@ConditionalOnProperty(value = "swagger.enabled", matchIfMissing = true) +@EnableSwagger2 +public class SwaggerConfiguration { + + @Bean + public Docket apiDocker() { + return new Docket(DocumentationType.SWAGGER_2) + .securitySchemes(Collections.singletonList(new OAuthBuilder() + .name("OAuth2") + .grantTypes(Collections.singletonList(new ResourceOwnerPasswordCredentialsGrant("/authz/oauth/token"))) + .build())) + .securityContexts(Collections.singletonList(SecurityContext.builder() + .securityReferences(Collections.singletonList(SecurityReference.builder() + .reference("OAuth2") + .scopes(new AuthorizationScope[]{}) + .build())) + .forPaths(PathSelectors.any()) + .build())); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/WebSecurityConfiguration.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/WebSecurityConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..bf2fd5b57ba8c7d5b9b577fd49df4cde031ed4b6 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/WebSecurityConfiguration.java @@ -0,0 +1,30 @@ +package cn.seqdata.oauth2; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.web.builders.WebSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +/** + * Author: jrxian + * Date: 2019-10-02 19:27 + */ +@Configuration +@EnableWebSecurity +public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { + + @Override + public void configure(WebSecurity web) { + web + .ignoring() + .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/v2/api-docs/**", "/webjars/**", "/plugins/**"); + } + + @Bean + @Override + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/ConnectController.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/ConnectController.java new file mode 100644 index 0000000000000000000000000000000000000000..3b8c65d90541e40e32f8b781ce6515ab3a32b827 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/ConnectController.java @@ -0,0 +1,249 @@ +package cn.seqdata.oauth2.controller; + +import java.net.URI; +import java.security.Principal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import javax.annotation.security.PermitAll; +import javax.servlet.http.HttpServletRequest; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.joda.time.DateTimeConstants; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import cn.seqdata.core.error.ErrorAttributes; +import cn.seqdata.crypto.exchanger.KeyExchanger; +import cn.seqdata.oauth2.OAuth2Constants; +import cn.seqdata.oauth2.jpa.oauth.UserDetail; +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import cn.seqdata.oauth2.nonce.NonceHandler; +import cn.seqdata.oauth2.provider.OAuth2Provider; +import cn.seqdata.oauth2.provider.OAuth2Template; +import cn.seqdata.oauth2.repos.oauth.UserDetailRepo; +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; +import cn.seqdata.oauth2.service.UserService; +import cn.seqdata.oauth2.util.SecurityUtils; +import cn.seqdata.syslog.Syslog; + +/** + * Author: jrxian + * Date: 2020-01-24 01:13 + */ +@Api("第三方账号绑定") +@RestController +@RequestMapping("/connect") +@lombok.RequiredArgsConstructor +public class ConnectController { + private final ClientRegistrationRepository repository; + private final UserService userService; + private final PasswordEncoder passwordEncoder; + private final UserAccountRepo accountRepo; + private final UserDetailRepo userDetailRepo; + private final List nonceHandlers; + private final KeyExchanger keyExchanger; + + @GetMapping + public Map list(Principal principal) { + Map connectors = new HashMap<>(); + + String grantType = SecurityUtils.grantType(principal); + String username = SecurityUtils.username(principal); + userService.loadUser(grantType, username) + .ifPresent(account -> { + connectors.put(OAuth2Constants.USERNAME, account.getUsername()); + connectors.put(OAuth2Constants.MOBILE, account.getMobile()); + connectors.put(OAuth2Constants.EMAIL, account.getEmail()); + + userDetailRepo.findByUserId(Objects.requireNonNull(account.getId())) + .stream() + .map(UserDetail::getId) + .filter(Objects::nonNull) + .forEach(x -> connectors.put(x.getClient(), x.getPrincipal())); + }); + + return connectors; + } + + /** + * 向手机或邮箱发送一次性验证码 + */ + @Syslog + @ApiOperation("发送验证码") + @RequestMapping(value = "/nonce", method = {RequestMethod.GET, RequestMethod.POST}) + @PermitAll + public ResponseEntity nonce(String username, @RequestParam(required = false) String clientId) { + boolean isSend = false; + for(NonceHandler nonceHandler : nonceHandlers) { + if(nonceHandler.support(username)) { + nonceHandler.nonce(username, 2 * DateTimeConstants.SECONDS_PER_MINUTE, clientId); + isSend = true; + } + } + if(isSend) { + return ResponseEntity.ok("验证码已经发送,2分钟内输入有效!"); + } else { + throw new AuthenticationServiceException("暂未实现验证码发送功能"); + } + } + + /** + * 向手机或邮箱发送一次性验证码 + */ + @Deprecated + @Syslog + @ApiOperation("发送验证码") + @RequestMapping(value = "/v2/nonce", method = {RequestMethod.GET, RequestMethod.POST}) + @PermitAll + public ResponseEntity nonce2(String username) { + boolean isSend = false; + for(NonceHandler nonceHandler : nonceHandlers) { + if(nonceHandler.support(username)) { + nonceHandler.nonceWithRedis(username, 2 * DateTimeConstants.SECONDS_PER_MINUTE); + isSend = true; + } + } + if(isSend) { + return ResponseEntity.ok("验证码已经发送,2分钟内输入有效!"); + } else { + throw new AuthenticationServiceException("暂未实现验证码发送功能"); + } + } + + /** + * 手机邮箱验证码校验 + * + * @author liang chenglong + * @date 2022-03-03 + */ + @Syslog + @ApiOperation("验证码校验") + @RequestMapping(value = "/verify",method = {RequestMethod.GET, RequestMethod.POST}) + public ResponseEntity verify(String username, int nonce){ + try { + for(NonceHandler nonceHandler : nonceHandlers) { + if(nonceHandler.support(username)) { + nonceHandler.check(username, nonce); + } + } + return ResponseEntity.ok("验证成功!"); + } catch(Exception e) { + throw new AuthenticationServiceException("验证失败,请填检查验证码是否正确!"); + } + } + + @Syslog + @ApiOperation("通过密码绑定") + @GetMapping(value = "/password") + public void bindPassword(String username, String password, Principal principal) { + UserAccount user = userService.loadUser(OAuth2Constants.PASSWORD, username) + .orElseThrow(() -> new UsernameNotFoundException("用户名不存在")); + + password = keyExchanger.decrypt(password); + if(!passwordEncoder.matches(password, user.getPassword())) { + throw new BadCredentialsException("用户名或密码不正确"); + } + + userService.bindUser(Objects.requireNonNull(user.getId()), + SecurityUtils.grantType(principal), + SecurityUtils.username(principal)); + } + + @Syslog + @ApiOperation("通过手机号绑定") + @GetMapping(value = "/mobile") + public void bindMobile(String mobile, int nonce, Principal principal) { + bindNonce(OAuth2Constants.MOBILE, mobile, nonce, principal); + } + + @Syslog + @ApiOperation("通过邮箱绑定") + @GetMapping(value = "/email") + public void bindEmail(String email, int nonce, Principal principal) { + bindNonce(OAuth2Constants.EMAIL, email, nonce, principal); + } + + @Syslog + @ApiOperation("第三方账号绑定") + @GetMapping(value = "/{registrationId}") + public ResponseEntity bind(@PathVariable String registrationId, HttpServletRequest request, Principal principal) { + ClientRegistration registration = repository.findByRegistrationId(registrationId); + OAuth2Provider provider = OAuth2Provider.provider(registration); + OAuth2Template oAuth2Template = provider.createOAuth2Template(registration); + + //从 request 中分离出 accessToken + String accessToken = SecurityUtils.accessToken(principal); + + //重定向 URI,返回信息由 OAuth2CodeController 负责接收 + String redirectUri = registration.getRedirectUriTemplate(); + if(Objects.isNull(redirectUri)) { + redirectUri = String.valueOf((request.getRequestURL())); + redirectUri = redirectUri.replace("/connect", "/oauth/code"); + } + + //将 accessToken 以 state 的形式传送出去,返回后用来判断被绑定的用户 + URI authorizeUri = oAuth2Template.buildAuthorizeURI(accessToken, redirectUri); + + return ResponseEntity.status(HttpStatus.SEE_OTHER) + .location(authorizeUri) + .build(); + } + + @Syslog + @ApiOperation("第三方账号解绑") + @Transactional + @DeleteMapping(value = "/{registrationId}") + public void unbind(@PathVariable String registrationId, Principal principal) { + String grantType = SecurityUtils.grantType(principal); + String username = SecurityUtils.username(principal); + Long userId = userService.loadUser(grantType, username) + .map(UserAccount::getId) + .orElseThrow(() -> new UsernameNotFoundException("要解绑的账号不存在")); + userDetailRepo.deleteByIdClientAndUserId(registrationId, userId); + } + + @Syslog + @ApiOperation("邮箱解绑") + @Transactional + @DeleteMapping("/email") + public void unbindEmail(Principal principal) { + String grantType = SecurityUtils.grantType(principal); + String username = SecurityUtils.username(principal); + UserAccount userAccount = userService.loadUser(grantType, username) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + userAccount.setEmail(null); + accountRepo.save(userAccount); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(AuthenticationException.class) + public ErrorAttributes authenticationException(AuthenticationException ex) { + return new ErrorAttributes(HttpStatus.BAD_REQUEST, ex.getMessage(), null); + } + + private void bindNonce(String grantType, String username, int nonce, Principal principal) { + for(NonceHandler nonceHandler : nonceHandlers) { + if(nonceHandler.support(username)) { + nonceHandler.check(username, nonce); + userService.loadUser(grantType, username) + .map(UserAccount::getId) + .ifPresent(userId -> userService.bindUser(userId, + SecurityUtils.grantType(principal), + SecurityUtils.username(principal))); + } + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/CurrentController.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/CurrentController.java new file mode 100644 index 0000000000000000000000000000000000000000..241d0c9b9b108907b77afc9fd9cf045957d2210a --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/CurrentController.java @@ -0,0 +1,246 @@ +package cn.seqdata.oauth2.controller; + +import java.security.Principal; +import java.util.Collection; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; +import org.springframework.security.oauth2.provider.token.ConsumerTokenServices; +import org.springframework.security.oauth2.provider.token.TokenStore; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import cn.seqdata.oauth2.OAuth2Constants; +import cn.seqdata.oauth2.jpa.rbac.User; +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import cn.seqdata.oauth2.params.UserAccountParam; +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; +import cn.seqdata.oauth2.repos.rbac.UserRepo; +import cn.seqdata.oauth2.service.AuthorityService; +import cn.seqdata.oauth2.service.DefaultTokenServices; +import cn.seqdata.oauth2.service.TokenService; +import cn.seqdata.oauth2.service.UserService; +import cn.seqdata.oauth2.util.SecurityUtils; +import cn.seqdata.syslog.Syslog; +import lombok.AllArgsConstructor; + +/** + * Author: jrxian + * Date: 2020-01-27 17:16 + */ +@Api("当前登录用户") +@RestController +@RequestMapping("/current") +@PreAuthorize("isAuthenticated()") +@AllArgsConstructor +public class CurrentController { + private static final String objectName = UserAccount.class.getSimpleName(); + + private final UserAccountRepo accountRepo; + private final UserRepo userRepo; + private final TokenStore tokenStore; + private final UserService userService; + private final ConsumerTokenServices consumerTokenServices; + private final AuthorityService authorityService; + private final TokenService tokenService; + + private final AuthorizationServerTokenServices tokenServices; + + @ApiOperation("通行证") + @GetMapping("/principal") + public Principal principal(Principal principal) { + return principal; + } + + @PreAuthorize("permitAll()") + @ApiOperation("token换取通行证") + @GetMapping("/token/{token}/principal") + public Principal principal(@PathVariable String token) { + return tokenStore.readAuthentication(token); + } + + @ApiOperation("用户信息") + @GetMapping("/user") + public Optional info(Principal principal) { + String grantType = SecurityUtils.grantType(principal); + String username = SecurityUtils.username(principal); + return userService.loadUser(grantType, username) + .map(UserAccount::getId) + .map(id -> { + User user = userRepo.getOne(Objects.requireNonNull(id)); + user.setAuthorities(authorityService.loadAuthorities(id)); + return user; + }); + } + + @ApiOperation("令牌") + @GetMapping("/token") + public OAuth2AccessToken token(Principal principal) { + return tokenStore.readAccessToken(SecurityUtils.accessToken(principal)); + } + + @PreAuthorize("permitAll()") + @ApiOperation("刷新token对应的角色关系") + @GetMapping("/token/refresh") + public void tokenRefresh(Principal principal) { + String grantType = SecurityUtils.grantType(principal); + String username = SecurityUtils.username(principal); + Collection newAuthorities = tokenService.loadAuthorities(grantType, username); + OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) principal; + + // 重新构造OAuth2Authentication + HashSet origAuthorities = (HashSet) oAuth2Authentication.getOAuth2Request() + .getAuthorities(); + origAuthorities.clear(); + origAuthorities.addAll(newAuthorities); + + Authentication userAuthentication = oAuth2Authentication.getUserAuthentication(); + if(userAuthentication != null) { + if(userAuthentication instanceof OAuth2AuthenticationToken) { + userAuthentication = + new OAuth2AuthenticationToken(((OAuth2AuthenticationToken) userAuthentication).getPrincipal(), + newAuthorities, ((OAuth2AuthenticationToken) userAuthentication).getAuthorizedClientRegistrationId()); + } else { + Authentication finalUserAuthentication = userAuthentication; + userAuthentication = new AbstractAuthenticationToken(newAuthorities) { + private static final long serialVersionUID = -1786107511865438515L; + + @Override + public Object getCredentials() { + return finalUserAuthentication.getCredentials(); + } + + @Override + public Object getPrincipal() { + return finalUserAuthentication.getPrincipal(); + } + + @Override + public boolean isAuthenticated() { + return true; + } + }; + } + } + OAuth2Authentication newOAuth2Authentication = new OAuth2Authentication(oAuth2Authentication.getOAuth2Request(), userAuthentication); + ((DefaultTokenServices) tokenServices).refreshAccessTokenAuthorities(oAuth2Authentication, newOAuth2Authentication); + } + + @ApiOperation("角色") + @GetMapping("/authorities") + public Collection authorities(Principal principal) { + return SecurityUtils.authorities(principal); + } + + @Syslog + @ApiOperation("注销") + @DeleteMapping + public void logout(Principal principal) { + consumerTokenServices.revokeToken(SecurityUtils.accessToken(principal)); + } + + @Syslog + @ApiOperation("修改昵称") + @PatchMapping(value = "/name") + public void updateName(Principal principal, @RequestBody UserAccountParam param) throws BindException { + UserAccount entity = getUserAccount(principal); + + BindException ex = new BindException(param, objectName); + checkField(ex, param, OAuth2Constants.NAME, UserAccountParam::getName); + checkErrors(ex); + + entity.setName(param.getName()); + accountRepo.save(entity); + } + + @Syslog + @ApiOperation("修改登录名") + @PatchMapping(value = "/username") + public void updateUsername(Principal principal, @RequestBody UserAccountParam param) throws BindException { + UserAccount entity = getUserAccount(principal); + + BindException ex = new BindException(param, objectName); + checkField(ex, param, OAuth2Constants.USERNAME, UserAccountParam::getUsername); + checkExist(ex, param, OAuth2Constants.USERNAME, entity, UserAccountParam::getUsername, accountRepo::findByUsername); + checkErrors(ex); + + entity.setUsername(param.getUsername()); + accountRepo.save(entity); + } + + @Syslog + @ApiOperation("修改手机号") + @PatchMapping(value = "/mobile") + public void updateMobile(Principal principal, @RequestBody UserAccountParam param) throws BindException { + UserAccount entity = getUserAccount(principal); + + BindException ex = new BindException(param, objectName); + checkField(ex, param, OAuth2Constants.MOBILE, UserAccountParam::getMobile); + checkExist(ex, param, OAuth2Constants.MOBILE, entity, UserAccountParam::getMobile, accountRepo::findByMobile); + checkErrors(ex); + + entity.setMobile(param.getMobile()); + accountRepo.save(entity); + } + + @Syslog + @ApiOperation("修改邮箱") + @PatchMapping(value = "/email") + public void updateEmail(Principal principal, @RequestBody UserAccountParam param) throws BindException { + UserAccount entity = getUserAccount(principal); + + BindException ex = new BindException(param, objectName); + checkField(ex, param, OAuth2Constants.EMAIL, UserAccountParam::getEmail); + checkExist(ex, param, OAuth2Constants.EMAIL, entity, UserAccountParam::getEmail, accountRepo::findByEmail); + checkErrors(ex); + + entity.setEmail(param.getEmail()); + accountRepo.save(entity); + } + + private UserAccount getUserAccount(Principal principal) { + String grantType = SecurityUtils.grantType(principal); + String username = SecurityUtils.username(principal); + return userService.loadUser(grantType, username) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + } + + private void checkField(BindException ex, UserAccountParam param, String fieldName, + Function function) { + if(StringUtils.isBlank(function.apply(param))) { + ex.addError(new FieldError(objectName, fieldName, fieldName + "不能为空")); + } + } + + private void checkExist(BindException ex, UserAccountParam param, String fieldName, UserAccount entity, + Function getter, Function> querier) { + querier.apply(getter.apply(param)) + .ifPresent(account -> { + if(!Objects.equals(entity.getId(), account.getId())) { + ex.addError(new FieldError(objectName, fieldName, fieldName + "已经被使用")); + } + }); + } + + private void checkErrors(BindException ex) throws BindException { + if(ex.hasErrors()) { + throw ex; + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/OAuth2CodeController.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/OAuth2CodeController.java new file mode 100644 index 0000000000000000000000000000000000000000..38fb279dc808b2ce60a34bcd2a7c7c83185f72b3 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/OAuth2CodeController.java @@ -0,0 +1,68 @@ +package cn.seqdata.oauth2.controller; + +import io.swagger.annotations.ApiOperation; +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.token.TokenStore; +import org.springframework.web.bind.annotation.*; + +import cn.seqdata.oauth2.provider.OAuth2Provider; +import cn.seqdata.oauth2.provider.OAuth2Template; +import cn.seqdata.oauth2.provider.UserProfile; +import cn.seqdata.oauth2.service.TokenService; +import cn.seqdata.oauth2.service.UserService; +import cn.seqdata.syslog.Syslog; +import lombok.AllArgsConstructor; + +/** + * Author: jrxian + * Date: 2020-01-24 02:09 + */ +@RestController +@RequestMapping("/oauth/code") +@AllArgsConstructor +public class OAuth2CodeController { + private final ClientRegistrationRepository repository; + private final TokenStore tokenStore; + private final UserService userService; + private final TokenService tokenService; + + @Syslog + @ApiOperation("authorization_code的回调函数,处理code和state") + @GetMapping(value = "/{registrationId}", params = OAuth2ParameterNames.CODE) + public OAuth2AccessToken bind(@PathVariable String registrationId, + @RequestParam(OAuth2ParameterNames.CODE) String code, + @RequestParam(value = OAuth2ParameterNames.STATE, required = false) String state) { + ClientRegistration registration = repository.findByRegistrationId(registrationId); + OAuth2Provider provider = OAuth2Provider.provider(registration); + OAuth2Template oAuth2Template = provider.createOAuth2Template(registration); + + //从远程拉取用户信息,以 registrationId + remoteUser.name 作为唯一识别符 + OAuth2AccessTokenResponse tokenResponse = oAuth2Template.exchangeForAccess(code, state); + OAuth2User remoteUser = oAuth2Template.loadUser(tokenResponse); + + UserProfile profile = provider.profile.apply(remoteUser); + //这里的 state 是 accessToken + OAuth2Authentication authentication = tokenStore.readAuthentication(state); + userService.updateUser(registrationId, profile, authentication); + // 注意,此处将registrationId作为clientId + return tokenService.createToken(registrationId, remoteUser); + } + + @ApiOperation("authorization_code的错误回调函数,处理error, error_description, error_uri") + @ResponseStatus(HttpStatus.BAD_REQUEST) + @GetMapping(value = "/{registrationId}", params = OAuth2ParameterNames.ERROR) + public OAuth2Error error(@PathVariable String registrationId, + @RequestParam(OAuth2ParameterNames.ERROR) String error, + @RequestParam(value = OAuth2ParameterNames.ERROR_DESCRIPTION, required = false) String errorDescription, + @RequestParam(value = OAuth2ParameterNames.ERROR_URI, required = false) String errorURI) { + return new OAuth2Error(error, errorDescription, errorURI); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/PasswordController.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/PasswordController.java new file mode 100644 index 0000000000000000000000000000000000000000..9d7fdd1496343e469229e7e35973ec2aa0d60a54 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/PasswordController.java @@ -0,0 +1,145 @@ +package cn.seqdata.oauth2.controller; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import javax.annotation.security.PermitAll; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.CredentialsExpiredException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.UserDetailsManager; +import org.springframework.web.bind.annotation.*; + +import cn.seqdata.core.error.ErrorAttributes; +import cn.seqdata.crypto.exchanger.KeyExchanger; +import cn.seqdata.oauth2.nonce.NonceHandler; +import cn.seqdata.oauth2.policy.PasswordPolicy; +import cn.seqdata.oauth2.policy.checker.PasswordChecker; +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; +import cn.seqdata.oauth2.service.AccountLockService; +import cn.seqdata.oauth2.service.JpaUserDetailsPasswordService; +import cn.seqdata.syslog.Syslog; +import lombok.AllArgsConstructor; + +/** + * @author jrxian + * @date 2020/11/28 11:07 + */ +@Api("密码管理") +@RestController +@RequestMapping("/password") +@AllArgsConstructor +public class PasswordController { + private final UserAccountRepo accountRepo; + private final AuthenticationManager authenticationManager; + private final UserDetailsManager userDetailsManager; + private final JpaUserDetailsPasswordService passwordService; + private final KeyExchanger keyExchanger; + private final PasswordPolicy passwordPolicy; + private final PasswordEncoder passwordEncoder; + private final PasswordChecker passwordChecker; + private final AccountLockService lockService; + private final List nonceHandlers; + + @PermitAll + @GetMapping("/key") + public String key() { + return keyExchanger.getPublicKey(); + } + + @Syslog + @ApiOperation("密码过期修改") + @PermitAll + @PostMapping + public void updatePassword(String username, String oldPassword, String newPassword) { + String decryptOldPswd = keyExchanger.decrypt(oldPassword); + String decryptNewPswd = keyExchanger.decrypt(newPassword); + Authentication authenticate = new UsernamePasswordAuthenticationToken(username, decryptOldPswd); + try { + //验证旧密码是否正确 + authenticate = authenticationManager.authenticate(authenticate); + } catch(CredentialsExpiredException ex) { + //密码已经过期,必须吃掉这个异常 + authenticate = new UsernamePasswordAuthenticationToken(username, decryptOldPswd, Collections.emptyList()); + } + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(authenticate); + userDetailsManager.changePassword(decryptOldPswd, decryptNewPswd); + SecurityContextHolder.clearContext(); + } + + @Syslog + @ApiOperation("通用验证码修改密码") + @PermitAll + @PostMapping("/nonce") + public void noncePassword(String mobile, int nonce, String username, String password) { + //验证码有效性检查 + for(NonceHandler nonceHandler : nonceHandlers) { + if(nonceHandler.support(mobile)) { + nonceHandler.check(mobile, nonce); + } + } + + //账号和手机号关联检查 + accountRepo.findByUsername(username) + .ifPresent(account -> { + if(!Objects.equals(account.getMobile(), mobile)) { + throw new AuthenticationServiceException("账户名与手机号不匹配"); + } + }); + + //检查密码强度 + String decryptPassword = keyExchanger.decrypt(password); + passwordChecker.accept(username, decryptPassword); + + //设置的新密码先编码,否则会触发密码升级,导致密码立即过期 + String encodedPassword = passwordEncoder.encode(decryptPassword); + passwordService.updatePassword(username, encodedPassword, passwordPolicy.getCredentialsExpireDate()); + } + + @Syslog + @ApiOperation("自己修改密码") + @PostMapping("/self") + @PreAuthorize("isAuthenticated()") + public void changePassword(String oldPassword, String newPassword) { + String decryptOldPswd = keyExchanger.decrypt(oldPassword); + String decryptNewPswd = keyExchanger.decrypt(newPassword); + userDetailsManager.changePassword(decryptOldPswd, decryptNewPswd); + } + + @Syslog + @ApiOperation("管理员修改密码") + @PostMapping("/admin") + @PreAuthorize("hasAuthority('sysadmin')") + public void updatePassword(String username, String password) { + String decryptPassword = keyExchanger.decrypt(password); + UserDetails userDetails = userDetailsManager.loadUserByUsername(username); + passwordService.updatePassword(userDetails, decryptPassword); + } + + @Syslog + @ApiOperation("解锁密码") + @PostMapping("/unlock") + @PreAuthorize("hasAuthority('sysadmin')") + public void unlockPassword(String username) { + lockService.unlock(username); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(AuthenticationException.class) + public ErrorAttributes authenticationException(AuthenticationException ex) { + return new ErrorAttributes(HttpStatus.BAD_REQUEST, ex.getMessage(), ex); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/RouterController.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/RouterController.java new file mode 100644 index 0000000000000000000000000000000000000000..5d2f7bb1ff242fcd3bf63e4bebf1f721e5d830ae --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/RouterController.java @@ -0,0 +1,96 @@ +package cn.seqdata.oauth2.controller; + +import java.security.Principal; +import java.util.*; +import java.util.stream.Collectors; + +import io.swagger.annotations.Api; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; +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.bind.annotation.RestController; +import com.google.common.collect.LinkedListMultimap; +import com.google.common.collect.Multimap; + +import cn.seqdata.antd.Router; +import cn.seqdata.oauth2.domain.PermissionType; +import cn.seqdata.oauth2.jpa.perm.ActionPermission; +import cn.seqdata.oauth2.jpa.perm.ModulePermission; +import cn.seqdata.oauth2.jpa.perm.ViewPermission; +import cn.seqdata.oauth2.jpa.rbac.AuthorityPermission; +import cn.seqdata.oauth2.jpa.rbac.Permission; +import cn.seqdata.oauth2.repos.perm.SysPermRepo; +import cn.seqdata.oauth2.repos.rbac.AuthorityPermissionRepo; +import cn.seqdata.oauth2.service.RouterService; +import cn.seqdata.oauth2.util.RouterUtils; +import lombok.AllArgsConstructor; + +/** + * Author: jrxian + * Date: 2020-03-22 10:04 + */ +@Api("路由信息") +@RestController +@RequestMapping("/router") +@AllArgsConstructor +public class RouterController { + private final AuthorityPermissionRepo permissionRepo; + private final SysPermRepo sysPermRepo; + private final RouterService service; + + @GetMapping + public Collection routers( + @RequestParam(required = false) Long sys, + @RequestParam(required = false) String id, + Principal principal, + @SortDefault("orderNo") Sort sort) { + + //获取有权限的全部module + Collection modulePerms = service.fetchModulePerms(principal, sort); + //获取有权限的全部action,并且按view做好分组 + Collection actionPerms = service.fetchActionPerms(principal, sort); + + //筛选指定角色下面的所有菜单 + Set topRouterIds = new HashSet<>(); + boolean hasId = !StringUtils.isBlank(id); + if(hasId) { + List authorityIds = Arrays.stream(StringUtils.split(id, ",")) + .map(Long::valueOf) + .collect(Collectors.toList()); + List specialAuthPerms = permissionRepo.findByAuthorityIdIn(authorityIds, Sort.unsorted()); + Set authPerms = new HashSet<>(); + specialAuthPerms.forEach(x -> { + Permission perm = x.getPermission(); + authPerms.add(perm.getId()); + if(Objects.equals(perm.getType(), PermissionType.sys)) { + topRouterIds.add(perm.getId()); + } + }); + modulePerms.removeIf(perm -> !authPerms.contains(perm.getId())); + actionPerms.removeIf(perm -> !authPerms.contains(perm.getId())); + } + Multimap actionPermMap = LinkedListMultimap.create(); + actionPerms.forEach(x -> { + ViewPermission viewPerm = x.getViewPerm(); + actionPermMap.put(viewPerm.getId(), x.getIdentifier()); + }); + + if(Objects.isNull(sys)) { + Collection routers = service.topRouters(principal, sort); + routers.removeIf(x -> hasId && !topRouterIds.contains(x.key)); + routers.forEach(router -> service.doRouter(router, modulePerms, actionPermMap)); + return routers; + } else { + //如果指定了sys,只查询本sys下的module + Router topRouter = RouterUtils.toRouter(sysPermRepo.getOne(sys)); + if(hasId && !topRouterIds.contains(topRouter.key)) { + return Collections.emptyList(); + } + service.doRouter(topRouter, modulePerms, actionPermMap); + return topRouter.children; + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/WxAppController.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/WxAppController.java new file mode 100644 index 0000000000000000000000000000000000000000..3ae9608ef088a1b572a07b680ec3b6c73b668cda --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/controller/WxAppController.java @@ -0,0 +1,177 @@ +package cn.seqdata.oauth2.controller; + +import java.io.IOException; +import java.security.Principal; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import com.fasterxml.jackson.databind.ObjectMapper; + +import cn.seqdata.oauth2.DefaultRole; +import cn.seqdata.oauth2.OAuth2Constants; +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import cn.seqdata.oauth2.service.TokenService; +import cn.seqdata.oauth2.service.UserService; +import cn.seqdata.oauth2.wechat.WechatUserProfile; +import cn.seqdata.oauth2.wxapp.WxAppService; +import cn.seqdata.syslog.Syslog; +import cn.seqdata.wxapp.message.Code2SessionResponse; +import cn.seqdata.wxapp.message.SchemeRequest; +import cn.seqdata.wxapp.message.UrlLinkRequest; +import cn.seqdata.wxapp.pojo.PhoneInfo; +import cn.seqdata.wxapp.pojo.UserInfo; +import cn.seqdata.wxapp.util.WxAppUtils; +import lombok.AllArgsConstructor; + +/** + * @author jrxian + * @date 2020-06-22 09:17 + */ +@Api("微信小程序") +@RestController +@RequestMapping("/wxapp/{id}") +@AllArgsConstructor +public class WxAppController { + private final ObjectMapper objectMapper; + private final WxAppService wxappService; + private final UserService userService; + private final TokenService tokenService; + + /** + * 获取用户信息并且创建rbac_user_info + */ + @Syslog + @ApiOperation("小程序注册") + @GetMapping("/signup") + @Transactional + public OAuth2AccessToken signup(@PathVariable("id") String id, String code, String iv, String encryptedData, @Nullable Principal principal) throws IOException { + Code2SessionResponse response = wxappService.signin(id, code); + String decryptedData = WxAppUtils.decryptData(response.session_key, iv, encryptedData); + UserInfo userInfo = objectMapper.readValue(decryptedData, UserInfo.class); + + Map attributes = new HashMap<>(); + WxAppUtils.toAttributes(attributes, userInfo); + WxAppUtils.toAttributes(attributes, response); + DefaultOAuth2User remoteUser = new DefaultOAuth2User(DefaultRole.NO_ROLES, attributes, WxAppUtils.WXAPP_ATTR_KEY); + WechatUserProfile profile = new WechatUserProfile(remoteUser); + userService.updateUser(id, profile, principal); + + return tokenService.createToken(id, remoteUser); + } + + /** + * 获取code换取openid登录 + */ + @Syslog + @ApiOperation("小程序登录") + @GetMapping("/signin") + @Transactional + public OAuth2AccessToken signin(@PathVariable("id") String id, String code, @Nullable Principal principal) { + Code2SessionResponse response = wxappService.signin(id, code); + + Map attributes = new HashMap<>(); + WxAppUtils.toAttributes(attributes, response); + DefaultOAuth2User remoteUser = new DefaultOAuth2User(DefaultRole.NO_ROLES, attributes, WxAppUtils.WXAPP_ATTR_KEY); + WechatUserProfile profile = new WechatUserProfile(remoteUser); + userService.updateUser(id, profile, principal); + + return tokenService.createToken(id, remoteUser); + } + + /** + * 获取微信手机号,绑定到现有账号 + */ + @ApiOperation("小程序手机号登录") + @GetMapping("/phone") + @Transactional + public OAuth2AccessToken phone(@PathVariable("id") String id, String code, String iv, String encryptedData) throws IOException { + Map attributes = attributes(id, code, iv, encryptedData); + + userService.loadUser(OAuth2Constants.MOBILE, String.valueOf(attributes.get("purePhoneNumber"))) + .map(UserAccount::getId) + .ifPresent(userId -> userService.bindUser(userId, id, String.valueOf(attributes.get("openid")))); + + DefaultOAuth2User remoteUser = new DefaultOAuth2User(DefaultRole.NO_ROLES, attributes, WxAppUtils.WXAPP_ATTR_KEY); + return tokenService.createToken(id, remoteUser); + } + + /** + * 获取微信手机号,绑定到现有账号 + */ + @ApiOperation("小程序手机号登录注册一体化-档案猫") + @GetMapping("/v2/phone") + @Transactional + public OAuth2AccessToken phoneV2(@PathVariable("id") String id, String code, String iv, String encryptedData) throws IOException { + Map attributes = attributes(id, code, iv, encryptedData); + String purePhoneNumber = String.valueOf(attributes.get("purePhoneNumber")); + Optional user = userService.loadUser(OAuth2Constants.MOBILE, purePhoneNumber); + if(user.isPresent()) { + // 如果手机号所在用户存在,则将openId从空user(只有openId)绑定到该手机号所在用户上 + user.map(UserAccount::getId) + .ifPresent(userId -> userService.bindUser(userId, id, String.valueOf(attributes.get("openid")))); + } else { + // 如果手机号所在用户不存在,则将手机号绑定到空user(只有openId)所在用户上 or 创建新用户 + userService.bindUserPhone(purePhoneNumber, id, String.valueOf(attributes.get("openid")), attributes); + } + + DefaultOAuth2User remoteUser = new DefaultOAuth2User(DefaultRole.NO_ROLES, attributes, WxAppUtils.WXAPP_ATTR_KEY); + return tokenService.createToken(id, remoteUser); + } + + /** + * 获取微信手机号,绑定到现有账号 + */ + @ApiOperation("解密信息小程序手机号") + @GetMapping("/phone/info") + @Transactional + public Map decryptData(@PathVariable("id") String id, String code, String iv, String encryptedData) throws IOException { + return attributes(id, code, iv, encryptedData); + } + + @GetMapping("/token") + public String token(@PathVariable("id") String id) { + return wxappService.accessToken(id); + } + + @DeleteMapping("/token") + public void token(@PathVariable("id") String id, @RequestParam("token") String token) { + wxappService.invalidToken(id, token); + } + + @ApiOperation("获取小程序scheme码") + @PostMapping("/scheme") + public String urlScheme(@PathVariable("id") String id, @RequestBody SchemeRequest request) { + return wxappService.scheme(id, request); + } + + @ApiOperation("获取小程序ticket") + @GetMapping("/ticket") + public String ticket(@PathVariable("id") String id) { + return wxappService.ticket(id); + } + + @ApiOperation("获取小程序 url_link") + @PostMapping("/url/link") + public String urlLink(@PathVariable("id") String id, @RequestBody UrlLinkRequest request) { + return wxappService.urlLink(id, request); + } + + private Map attributes(String id, String code, String iv, String encryptedData) throws IOException { + Code2SessionResponse response = wxappService.signin(id, code); + String decryptedData = WxAppUtils.decryptData(response.session_key, iv, encryptedData); + PhoneInfo phoneInfo = objectMapper.readValue(decryptedData, PhoneInfo.class); + + Map attributes = new HashMap<>(); + WxAppUtils.toAttributes(attributes, phoneInfo); + WxAppUtils.toAttributes(attributes, response); + return attributes; + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/AddressFilter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/AddressFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..077882e8628b496404f57c7694975950d736c36b --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/AddressFilter.java @@ -0,0 +1,22 @@ +package cn.seqdata.oauth2.filter; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** + * @author jrxian + * @date 2020/12/14 22:43 + */ +@lombok.RequiredArgsConstructor +public class AddressFilter extends RequestFilter { + private final AddressProperties properties; + + @Override + protected void internalFilter(HttpServletRequest request) throws ResponseStatusException { + if(!properties.test(request.getRemoteAddr())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "不允许在此客户端地址登录"); + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/AddressProperties.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/AddressProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..4482a435edd5c2f011001f1821898e051ac5675a --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/AddressProperties.java @@ -0,0 +1,55 @@ +package cn.seqdata.oauth2.filter; + +import java.util.Objects; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.StringUtils; + +/** + * @author jrxian + * @date 2020/12/15 0:34 + * 只能在固定的地址范围登录 + */ +@lombok.Getter +@lombok.Setter +@ConfigurationProperties("spring.security.filter.address") +public class AddressProperties implements Predicate { + private Pattern allow; + private Pattern deny; + + public String getAllow() { + return Objects.nonNull(allow) ? allow.toString() : null; + } + + public void setAllow(String allow) { + this.allow = StringUtils.isEmpty(allow) ? null : Pattern.compile(allow); + } + + public String getDeny() { + return Objects.nonNull(deny) ? deny.toString() : null; + } + + public void setDeny(String deny) { + this.deny = StringUtils.isEmpty(deny) ? null : Pattern.compile(deny); + } + + @Override + public boolean test(String property) { + if(Objects.nonNull(deny) && matches(deny.matcher(property))) { + return false; + } + + if(Objects.nonNull(allow) && matches(allow.matcher(property))) { + return true; + } + + return Objects.isNull(deny) && Objects.isNull(allow); + } + + private static boolean matches(Matcher matcher) { + return matcher.matches(); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/PeriodFilter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/PeriodFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..6c1bef2c1817fa74f665fd27742c83677ef22424 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/PeriodFilter.java @@ -0,0 +1,24 @@ +package cn.seqdata.oauth2.filter; + +import javax.servlet.http.HttpServletRequest; + +import org.joda.time.LocalTime; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** + * @author jrxian + * @date 2020/12/15 0:56 + * 只能在固定的时间段登录 + */ +@lombok.RequiredArgsConstructor +public class PeriodFilter extends RequestFilter { + private final PeriodProperties properties; + + @Override + protected void internalFilter(HttpServletRequest request) throws ResponseStatusException { + if(!properties.test(LocalTime.now())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "不允许在此时段登录"); + } + } +} \ No newline at end of file diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/PeriodProperties.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/PeriodProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..7acfd11bd8466a7b3f74e4e7f3a14e4ee0f8ba93 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/PeriodProperties.java @@ -0,0 +1,56 @@ +package cn.seqdata.oauth2.filter; + +import java.util.LinkedList; +import java.util.Objects; +import java.util.function.Predicate; + +import org.apache.commons.lang3.ObjectUtils; +import org.joda.time.DateTimeConstants; +import org.joda.time.LocalTime; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author jrxian + * @date 2020/12/15 0:49 + */ +@ConfigurationProperties("spring.security.filter.period") +public class PeriodProperties extends LinkedList implements Predicate { + + @Override + public boolean test(LocalTime time) { + if(isEmpty()) { + return true; + } + + for(PeriodProperties.Item i : this) { + LocalTime start = ObjectUtils.defaultIfNull(i.start, LocalTime.MIDNIGHT); + LocalTime end = ObjectUtils.defaultIfNull(i.end, LocalTime.fromMillisOfDay(DateTimeConstants.MILLIS_PER_DAY - 1)); + if(time.isAfter(start) && time.isBefore(end)) { + return true; + } + } + + return false; + } + + public static class Item { + private LocalTime start; + private LocalTime end; + + public String getStart() { + return Objects.nonNull(start) ? start.toString() : null; + } + + public void setStart(String start) { + this.start = Objects.nonNull(start) ? LocalTime.parse(start) : null; + } + + public String getEnd() { + return Objects.nonNull(end) ? end.toString() : null; + } + + public void setEnd(String end) { + this.end = Objects.nonNull(end) ? LocalTime.parse(end) : null; + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/RequestFilter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/RequestFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..59aa511630d67efc068281d086a5052d5e33007a --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/filter/RequestFilter.java @@ -0,0 +1,62 @@ +package cn.seqdata.oauth2.filter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.server.ResponseStatusException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author jrxian + * @date 2020/12/15 0:17 + */ +public abstract class RequestFilter implements Filter { + private final ObjectMapper objectMapper = new ObjectMapper(); + + protected abstract void internalFilter(HttpServletRequest request) throws ResponseStatusException; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + try { + if(request instanceof HttpServletRequest) { + HttpServletRequest httpRequest = (HttpServletRequest) request; + String requestURI = httpRequest.getRequestURI(); + if(requestURI.startsWith("/oauth/token") || requestURI.startsWith("/connect")) { + internalFilter(httpRequest); + } + } + chain.doFilter(request, response); + } catch(ResponseStatusException ex) { + if(response instanceof HttpServletResponse) { + HttpServletResponse httpResponse = (HttpServletResponse) response; + + HttpStatus httpStatus = ex.getStatus(); + httpResponse.setStatus(httpStatus.value()); + + Map headers = ex.getHeaders(); + headers.forEach(httpResponse::setHeader); + + httpResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(httpResponse.getOutputStream(), createErrorAttributes(ex)); + } + } + } + + private Map createErrorAttributes(ResponseStatusException rse) { + Map errorAttributes = new HashMap<>(); + + HttpStatus httpStatus = rse.getStatus(); + errorAttributes.put("timestamp", System.currentTimeMillis()); + errorAttributes.put("status", httpStatus.value()); + errorAttributes.put("error", httpStatus.getReasonPhrase()); + errorAttributes.put("message", rse.getReason()); + + return errorAttributes; + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/github/GithubUserProfile.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/github/GithubUserProfile.java new file mode 100644 index 0000000000000000000000000000000000000000..fbad90ca1f4d019f8fddf2c5805b52db9c7c7b80 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/github/GithubUserProfile.java @@ -0,0 +1,31 @@ +package cn.seqdata.oauth2.github; + +import org.springframework.security.oauth2.core.user.OAuth2User; + +import cn.seqdata.oauth2.provider.UserProfile; + +/** + * Author: jrxian + * Date: 2020-01-25 08:36 + */ +public class GithubUserProfile extends UserProfile { + + public GithubUserProfile(OAuth2User delegate) { + super(delegate); + } + + @Override + public String getNickname() { + return getAttribute("name"); + } + + @Override + public String getUsername() { + return getAttribute("login"); + } + + @Override + public String getAvatar() { + return getAttribute("avatar_url"); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/AuthenticationBean.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/AuthenticationBean.java new file mode 100644 index 0000000000000000000000000000000000000000..588672a684bd408a45d1701002c112b93f11a3e7 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/AuthenticationBean.java @@ -0,0 +1,43 @@ +package cn.seqdata.oauth2.jackson; + +import java.util.*; +import java.util.function.Consumer; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * @author jrxian + * @date 2020-11-15 17:48 + */ +@lombok.Getter +@lombok.Setter +@lombok.NoArgsConstructor +public class AuthenticationBean implements Authentication, Consumer { + private String name; + private final UserBean principal = new UserBean(); + private Object credentials; + private final Set roles = new HashSet<>(); + private Map details = new HashMap<>(); + private boolean authenticated; + + @JsonIgnore + @Override + public Collection getAuthorities() { + return AuthorityConverter.toAuthorities(roles); + } + + @Override + public void accept(Authentication authentication) { + this.name = authentication.getName(); + this.principal.accept(authentication.getPrincipal()); + this.credentials = authentication.getCredentials(); + this.roles.addAll(AuthorityConverter.toRoles(authentication.getAuthorities())); + Object details = authentication.getDetails(); + if(details instanceof Map) { + ((Map) details).forEach((k, v) -> this.details.put(String.valueOf(k), String.valueOf(v))); + } + this.authenticated = authentication.isAuthenticated(); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/AuthorityConverter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/AuthorityConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..5fd1ddf3bee5cbe93622fd85db0638d2f70f820a --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/AuthorityConverter.java @@ -0,0 +1,28 @@ +package cn.seqdata.oauth2.jackson; + +import java.util.Collection; +import java.util.stream.Collectors; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * @author jrxian + * @date 2020-11-15 17:56 + */ +public final class AuthorityConverter { + private AuthorityConverter() { + } + + public static Collection toAuthorities(Collection roles) { + return roles.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + public static Collection toRoles(Collection authorities) { + return authorities.stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/JsonSerializationStrategy.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/JsonSerializationStrategy.java new file mode 100644 index 0000000000000000000000000000000000000000..3d1557c85148ff9cbb6c8bc465cd613767145d51 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/JsonSerializationStrategy.java @@ -0,0 +1,38 @@ +package cn.seqdata.oauth2.jackson; + +import java.io.IOException; + +import org.springframework.security.oauth2.provider.token.store.redis.StandardStringSerializationStrategy; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Author: jrxian + * Date: 2020-02-15 13:37 + */ +public class JsonSerializationStrategy extends StandardStringSerializationStrategy { + private final ObjectMapper objectMapper = new ObjectMapper(); + + public JsonSerializationStrategy() { + objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + objectMapper.registerModule(new OAuth2Module()); + } + + @Override + protected byte[] serializeInternal(Object object) { + try { + return objectMapper.writeValueAsBytes(object); + } catch(IOException ex) { + throw new RuntimeException(ex); + } + } + + @Override + protected T deserializeInternal(byte[] bytes, Class clazz) { + try { + return objectMapper.readValue(bytes, clazz); + } catch(IOException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2AuthenticationBean.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2AuthenticationBean.java new file mode 100644 index 0000000000000000000000000000000000000000..2d57ab9ecb6d3178c8472fd952cfa533714703fc --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2AuthenticationBean.java @@ -0,0 +1,29 @@ +package cn.seqdata.oauth2.jackson; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.springframework.security.oauth2.provider.OAuth2Authentication; + +/** + * @author jrxian + * @date 2020-11-15 18:04 + */ +@lombok.Getter +@lombok.Setter +@lombok.NoArgsConstructor +public class OAuth2AuthenticationBean implements Consumer, Supplier { + private final OAuth2RequestBean storedRequest = new OAuth2RequestBean(); + private final AuthenticationBean userAuthentication = new AuthenticationBean(); + + @Override + public void accept(OAuth2Authentication wrapper) { + storedRequest.accept(wrapper.getOAuth2Request()); + userAuthentication.accept(wrapper.getUserAuthentication()); + } + + @Override + public OAuth2Authentication get() { + return new OAuth2Authentication(storedRequest.get(), userAuthentication); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2AuthenticationMixIn.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2AuthenticationMixIn.java new file mode 100644 index 0000000000000000000000000000000000000000..66818be1c011e00baa76243ef56a993600ec868a --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2AuthenticationMixIn.java @@ -0,0 +1,17 @@ +package cn.seqdata.oauth2.jackson; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.provider.OAuth2Request; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author jrxian + * @date 2020-11-15 00:21 + */ +public class OAuth2AuthenticationMixIn { + @JsonCreator + public OAuth2AuthenticationMixIn(@JsonProperty("oauth2Request") OAuth2Request storedRequest, + @JsonProperty("userAuthentication") Authentication userAuthentication) { + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2Module.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2Module.java new file mode 100644 index 0000000000000000000000000000000000000000..64f8d201a59bba279d41f9006ed412d2d3d1a157 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2Module.java @@ -0,0 +1,39 @@ +package cn.seqdata.oauth2.jackson; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +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.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.common.OAuth2AccessTokenJackson2Deserializer; +import org.springframework.security.oauth2.common.OAuth2AccessTokenJackson2Serializer; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.OAuth2Request; +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.module.SimpleModule; + +/** + * @author jrxian + * @date 2020-11-15 00:16 + */ +public class OAuth2Module extends SimpleModule { + + public OAuth2Module() { + super(OAuth2Module.class.getSimpleName(), Version.unknownVersion()); + + setMixInAnnotation(SimpleGrantedAuthority.class, SimpleGrantedAuthorityMixIn.class); + setMixInAnnotation(User.class, UserMixIn.class); + setMixInAnnotation(UsernamePasswordAuthenticationToken.class, UsernamePasswordAuthenticationTokenMinIn.class); + setMixInAnnotation(OAuth2Request.class, OAuth2RequestMixIn.class); + setMixInAnnotation(OAuth2Authentication.class, OAuth2AuthenticationMixIn.class); + + addAbstractTypeMapping(GrantedAuthority.class, SimpleGrantedAuthority.class); + addAbstractTypeMapping(UserDetails.class, UserBean.class); + addAbstractTypeMapping(Authentication.class, UsernamePasswordAuthenticationToken.class); + + addSerializer(OAuth2AccessToken.class, new OAuth2AccessTokenJackson2Serializer()); + addDeserializer(OAuth2AccessToken.class, new OAuth2AccessTokenJackson2Deserializer()); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2RequestBean.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2RequestBean.java new file mode 100644 index 0000000000000000000000000000000000000000..4022c757075082f0a010f66afb457e499a55d9f1 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2RequestBean.java @@ -0,0 +1,57 @@ +package cn.seqdata.oauth2.jackson; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.springframework.security.oauth2.provider.OAuth2Request; + +/** + * @author jrxian + * @date 2020-11-15 17:38 + */ +@lombok.Getter +@lombok.Setter +@lombok.NoArgsConstructor +public class OAuth2RequestBean implements Consumer, Supplier { + private String clientId; + private final Set scope = new HashSet<>(); + private final Map requestParameters = new HashMap<>(); + private final Set resourceIds = new HashSet<>(); + private final Set roles = new HashSet<>(); + private boolean approved; + private String redirectUri; + private final Set responseTypes = new HashSet<>(); + private final Map extensions = new HashMap<>(); + + @Override + public void accept(OAuth2Request wrapper) { + this.clientId = wrapper.getClientId(); + this.scope.addAll(wrapper.getScope()); + this.requestParameters.putAll(wrapper.getRequestParameters()); + this.resourceIds.addAll(wrapper.getResourceIds()); + this.roles.addAll(AuthorityConverter.toRoles(wrapper.getAuthorities())); + this.approved = wrapper.isApproved(); + this.redirectUri = wrapper.getRedirectUri(); + this.responseTypes.addAll(wrapper.getResponseTypes()); + this.extensions.putAll(wrapper.getExtensions()); + } + + @Override + public OAuth2Request get() { + return new OAuth2Request(requestParameters, + clientId, + AuthorityConverter.toAuthorities(roles), + approved, + scope, + resourceIds, + redirectUri, + responseTypes, + extensions + ); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2RequestMixIn.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2RequestMixIn.java new file mode 100644 index 0000000000000000000000000000000000000000..7557a144f3c58217840ae36ad9459fb7bb3a700e --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/OAuth2RequestMixIn.java @@ -0,0 +1,31 @@ +package cn.seqdata.oauth2.jackson; + +import java.io.Serializable; +import java.util.Collection; +import java.util.Map; +import java.util.Set; + +import org.springframework.security.core.GrantedAuthority; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author jrxian + * @date 2020-11-15 00:22 + */ +@JsonIgnoreProperties("refresh") +public class OAuth2RequestMixIn { + @JsonCreator + public OAuth2RequestMixIn( + @JsonProperty("requestParameters") Map requestParameters, + @JsonProperty("clientId") String clientId, + @JsonProperty("authorities") Collection authorities, + @JsonProperty("approved") boolean approved, + @JsonProperty("scope") Set scope, + @JsonProperty("resourceIds") Set resourceIds, + @JsonProperty("redirectUri") String redirectUri, + @JsonProperty("responseTypes") Set responseTypes, + @JsonProperty("extensions") Map extensionProperties) { + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/SimpleGrantedAuthorityMixIn.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/SimpleGrantedAuthorityMixIn.java new file mode 100644 index 0000000000000000000000000000000000000000..4ec829fc3abe9487c3f41e223ca752d0b2d05f5f --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/SimpleGrantedAuthorityMixIn.java @@ -0,0 +1,14 @@ +package cn.seqdata.oauth2.jackson; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author jrxian + * @date 2020-11-15 11:06 + */ +public class SimpleGrantedAuthorityMixIn { + @JsonCreator + public SimpleGrantedAuthorityMixIn(@JsonProperty("authority") String authority) { + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/UserBean.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/UserBean.java new file mode 100644 index 0000000000000000000000000000000000000000..5f47f951881e11eb4fbf68d54b34fb0cb722360a --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/UserBean.java @@ -0,0 +1,70 @@ +package cn.seqdata.oauth2.jackson; + +import java.security.Principal; +import java.util.Collection; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.AuthenticatedPrincipal; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * @author jrxian + * @date 2020-11-15 23:42 + */ +@lombok.Getter +@lombok.Setter +@lombok.NoArgsConstructor +public class UserBean implements UserDetails, Consumer { + private String username; + private String password; + private boolean enabled; + private boolean accountNonExpired; + private boolean credentialsNonExpired; + private boolean accountNonLocked; + private final Set roles = new HashSet<>(); + + @JsonIgnore + @Override + public Collection getAuthorities() { + return AuthorityConverter.toAuthorities(roles); + } + + @Override + public void accept(Object wrapper) { + if(wrapper instanceof Principal) { + this.username = ((Principal) wrapper).getName(); + } else if(wrapper instanceof AuthenticatedPrincipal) { + this.username = ((AuthenticatedPrincipal) wrapper).getName(); + } else if(wrapper instanceof UserDetails) { + UserDetails userDetails = (UserDetails) wrapper; + this.username = userDetails.getUsername(); + this.password = userDetails.getPassword(); + this.enabled = userDetails.isEnabled(); + this.accountNonExpired = userDetails.isAccountNonExpired(); + this.credentialsNonExpired = userDetails.isCredentialsNonExpired(); + this.accountNonExpired = userDetails.isAccountNonLocked(); + this.roles.addAll(AuthorityConverter.toRoles(userDetails.getAuthorities())); + } + } + + @Override + public boolean equals(Object o) { + if(this == o) return true; + if(o instanceof UserDetails) { + return StringUtils.equals(getUsername(), ((UserDetails) o).getUsername()); + } else { + return false; + } + } + + @Override + public int hashCode() { + return Objects.hash(username); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/UserMixIn.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/UserMixIn.java new file mode 100644 index 0000000000000000000000000000000000000000..7fc4e15d8b830299a7debd123f5da901bbb2caa6 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/UserMixIn.java @@ -0,0 +1,24 @@ +package cn.seqdata.oauth2.jackson; + +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author jrxian + * @date 2020-11-15 23:47 + */ +public class UserMixIn { + @JsonCreator + public UserMixIn( + @JsonProperty("username") String username, + @JsonProperty("password") String password, + @JsonProperty("enabled") boolean enabled, + @JsonProperty("accountNonExpired") boolean accountNonExpired, + @JsonProperty("credentialsNonExpired") boolean credentialsNonExpired, + @JsonProperty("accountNonLocked") boolean accountNonLocked, + @JsonProperty("authorities") Collection authorities) { + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/UsernamePasswordAuthenticationTokenMinIn.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/UsernamePasswordAuthenticationTokenMinIn.java new file mode 100644 index 0000000000000000000000000000000000000000..2c378597484e00a362f8cfcf28d6ff9234474daa --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/jackson/UsernamePasswordAuthenticationTokenMinIn.java @@ -0,0 +1,19 @@ +package cn.seqdata.oauth2.jackson; + +import org.springframework.security.core.userdetails.UserDetails; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author jrxian + * @date 2020-11-15 10:46 + */ +@JsonIgnoreProperties("authenticated") +public class UsernamePasswordAuthenticationTokenMinIn { + @JsonCreator + public UsernamePasswordAuthenticationTokenMinIn( + @JsonProperty("principal") UserDetails principal, + @JsonProperty("credentials") Object credentials) { + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/AbstractPasswordTokenGranter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/AbstractPasswordTokenGranter.java new file mode 100644 index 0000000000000000000000000000000000000000..23deddce76f3ddee33eaa44f534044ea578a38c1 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/AbstractPasswordTokenGranter.java @@ -0,0 +1,70 @@ +package cn.seqdata.oauth2.nonce; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.common.exceptions.InvalidGrantException; +import org.springframework.security.oauth2.provider.*; +import org.springframework.security.oauth2.provider.token.AbstractTokenGranter; +import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * @author jrxian + * @date 2020/12/14 1:56 + */ +public class AbstractPasswordTokenGranter extends AbstractTokenGranter { + private final AuthenticationManager authenticationManager; + + protected AbstractPasswordTokenGranter(AuthenticationManager authenticationManager, + AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, + OAuth2RequestFactory requestFactory, String grantType) { + super(tokenServices, clientDetailsService, requestFactory, grantType); + this.authenticationManager = authenticationManager; + } + + @Override + protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) { + Map parameters = new LinkedHashMap<>(tokenRequest.getRequestParameters()); + parameters.put("clientId", client.getClientId()); + String username = obtainUsername(tokenRequest); + String password = obtainPassword(tokenRequest); + + Authentication userAuth = new UsernamePasswordAuthenticationToken(username, password); + ((AbstractAuthenticationToken) userAuth).setDetails(parameters); + try { + userAuth = authenticationManager.authenticate(userAuth); + } catch(AccountStatusException | BadCredentialsException ex) { + InvalidGrantException exception = new InvalidGrantException(ex.getMessage(), ex); + //给前端输出具体的错误类型,便于前端处理 + //主要解决首次登录/管理员重置密码后必须修改密码才能登录的问题 + Class exClass = ex.getClass(); + String exClassName = StringUtils.replace(exClass.getSimpleName(), "Exception", ""); + exception.addAdditionalInformation("exception", exClassName); + throw exception; + } + if(userAuth == null || !userAuth.isAuthenticated()) { + throw new InvalidGrantException("Could not authenticate user: " + username); + } + + OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest); + return new OAuth2Authentication(storedOAuth2Request, userAuth); + } + + @Nullable + protected String obtainUsername(TokenRequest request) { + Map parameters = request.getRequestParameters(); + return parameters.get(UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY); + } + + @Nullable + protected String obtainPassword(TokenRequest request) { + Map parameters = request.getRequestParameters(); + return parameters.get(UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_PASSWORD_KEY); + } +} \ No newline at end of file diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/AliyunSmsService.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/AliyunSmsService.java new file mode 100644 index 0000000000000000000000000000000000000000..4788f422487f63afde055135087f1a17b208d1f3 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/AliyunSmsService.java @@ -0,0 +1,70 @@ +package cn.seqdata.oauth2.nonce; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiConsumer; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import com.aliyuncs.DefaultAcsClient; +import com.aliyuncs.IAcsClient; +import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest; +import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse; +import com.aliyuncs.exceptions.ClientException; +import com.aliyuncs.exceptions.ServerException; +import com.aliyuncs.profile.DefaultProfile; +import com.fasterxml.jackson.databind.ObjectMapper; + +import cn.seqdata.oauth2.AliyunProperties; +import cn.seqdata.oauth2.AliyunSmsProperties; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +/** + * @author jrxian + * @date 2020-06-21 19:00 + */ +@Slf4j +public class AliyunSmsService implements BiConsumer { + private final IAcsClient acsClient; + private final AliyunSmsProperties properties; + + public AliyunSmsService(AliyunProperties properties, AliyunSmsProperties smsProperties) { + this.properties = smsProperties; + DefaultProfile profile = DefaultProfile.getProfile(smsProperties.getRegionId(), properties.getAccessKey(), properties.getAccessSecret()); + acsClient = new DefaultAcsClient(profile); + } + + @Override + public void accept(String mobile, Integer nonce) { + SendSmsRequest request = new SendSmsRequest(); + + request.setPhoneNumbers(mobile); + request.setTemplateCode(properties.getTemplateCode()); + request.setSignName(properties.getSignName()); + + Map templateParams = new HashMap<>(); + templateParams.put("code", Integer.toString(nonce)); + request.setTemplateParam(toJSONString(templateParams)); + try { + SendSmsResponse response = acsClient.getAcsResponse(request); + if(!StringUtils.equals("OK", response.getCode())) { + log.error("{}发送验证码失败,原因:{}", mobile, response.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "验证码发送失败,请稍后再试"); + } + } catch(ClientException ex) { + log.warn(ex.getMessage()); + if(ex instanceof ServerException) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, ex.getErrMsg()); + } else { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ex.getErrMsg()); + } + } + } + + @SneakyThrows + private String toJSONString(Object value) { + return new ObjectMapper().writeValueAsString(value); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/EmailNonceHandler.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/EmailNonceHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..96212606af0c994942c758dc72a55b7e44057e84 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/EmailNonceHandler.java @@ -0,0 +1,53 @@ +package cn.seqdata.oauth2.nonce; + +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.autoconfigure.mail.MailProperties; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.MailSender; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.web.server.ServerWebInputException; +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; + +/** + * @author jrxian + * @date 2020-06-21 21:26 + */ +public class EmailNonceHandler extends NonceHandlerImpl { + private final MailSender mailSender; + private final MailProperties mailProperties; + + public EmailNonceHandler(UserAccountRepo accountRepo, MailSender mailSender, MailProperties mailProperties, + StringRedisTemplate redisTemplate) { + super(accountRepo, redisTemplate); + this.mailSender = mailSender; + this.mailProperties = mailProperties; + } + + @Override + public boolean support(String username) { + return StringUtils.containsAny(username, '@'); + } + + @Override + protected Optional findAccount(String username) { + return accountRepo.findByEmail(username); + } + + @Override + protected UserAccount saveAccount(String username, String clientId) { + throw new ServerWebInputException("账户不存在"); + } + + @Override + protected void sendNonce(String username, int nonce) { + SimpleMailMessage mailMessage = new SimpleMailMessage(); + mailMessage.setFrom(mailProperties.getUsername()); + mailMessage.setTo(username); + mailMessage.setSubject("获取验证码"); + mailMessage.setText(String.format("验证码: %06d", nonce)); + mailSender.send(mailMessage); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/MobileNonceHandler.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/MobileNonceHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..b7299c9140e4b79cea08c0fa812f05bde746a55b --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/MobileNonceHandler.java @@ -0,0 +1,53 @@ +package cn.seqdata.oauth2.nonce; + +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.StringRedisTemplate; +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; +import cn.seqdata.oauth2.service.UserService; + +/** + * @author jrxian + * @date 2020-06-21 21:26 + */ +public class MobileNonceHandler extends NonceHandlerImpl { + private final AliyunSmsService smsService; + private final UserService userService; + + public MobileNonceHandler(UserAccountRepo accountRepo, UserService userService, + AliyunSmsService smsService, StringRedisTemplate redisTemplate) { + super(accountRepo, redisTemplate); + this.smsService = smsService; + this.userService = userService; + } + + @Override + public boolean support(String username) { + return !StringUtils.containsAny(username, '@'); + } + + @Override + protected Optional findAccount(String username) { + return accountRepo.findByMobile(username); + } + + @Override + protected UserAccount saveAccount(String username, String clientId) { + String name = String.format("手机用户%s", StringUtils.substring(username, username.length() - 4)); + long userId = userService.saveUser(name, null); + UserAccount user = accountRepo.findById(userId) + .orElse(new UserAccount(userId)); + user.setMobile(username); + user = accountRepo.save(user); + // 分配默认角色 + userService.saveDefaultAuthorities(userId, clientId); + return user; + } + + @Override + protected void sendNonce(String username, int nonce) { + smsService.accept(username, nonce); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/Nonce.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/Nonce.java new file mode 100644 index 0000000000000000000000000000000000000000..dcfae98fa79b827936f7d62f35fd6d83b8314df1 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/Nonce.java @@ -0,0 +1,21 @@ +package cn.seqdata.oauth2.nonce; + +import java.io.Serializable; + +import lombok.*; + +/** + * @author Lin.Zhang + * @date 2021/6/25 + */ +@Getter +@Setter +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class Nonce implements Serializable { + private static final long serialVersionUID = 1L; + + private Integer nonceCode; + private Long expire; +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceAuthenticationFilter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceAuthenticationFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..abe7de1d0af7d26e672e4ef9bae4d74a5f139e66 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceAuthenticationFilter.java @@ -0,0 +1,65 @@ +package cn.seqdata.oauth2.nonce; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import cn.seqdata.oauth2.OAuth2Constants; + +/** + * Author: jrxian + * Date: 2020-01-22 12:06 + */ +public class NonceAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + private final String grantType; + private boolean postOnly = true; + + public NonceAuthenticationFilter(String grantType) { + super(new AntPathRequestMatcher("/" + grantType, HttpMethod.POST.name())); + this.grantType = grantType; + } + + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + if(postOnly && !StringUtils.equals(HttpMethod.POST.name(), request.getMethod())) { + throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); + } + + String username = obtainUsername(request); + String password = obtainPassword(request); + + UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); + authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); + + return getAuthenticationManager().authenticate(authRequest); + } + + protected String obtainUsername(HttpServletRequest request) { + String username = request.getParameter(UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY); + if(StringUtils.isBlank(username)) { + username = request.getParameter(grantType); + } + return ObjectUtils.defaultIfNull(username, StringUtils.EMPTY); + } + + protected String obtainPassword(HttpServletRequest request) { + String password = request.getParameter(UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_PASSWORD_KEY); + if(StringUtils.isBlank(password)) { + password = request.getParameter(OAuth2Constants.NONCE); + } + return ObjectUtils.defaultIfNull(password, StringUtils.EMPTY); + } + + public void setPostOnly(boolean postOnly) { + this.postOnly = postOnly; + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceAuthenticationProvider.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceAuthenticationProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..70fef141851c618ddf17875e3064e5ffe46731cb --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceAuthenticationProvider.java @@ -0,0 +1,66 @@ +package cn.seqdata.oauth2.nonce; + +import java.util.List; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.apache.commons.lang3.math.NumberUtils; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import cn.seqdata.oauth2.service.JpaUserDetailsManager; +import lombok.AllArgsConstructor; + +/** + * Author: jrxian + * Date: 2020-01-26 00:52 + */ +@AllArgsConstructor +public class NonceAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { + private final BiFunction userDetailsLoader; + private final JpaUserDetailsManager userDetailsManager; + private final List nonceHandlers; + private final NonceUserService userService; + + @Override + protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { + try { + return Optional.ofNullable(userDetailsLoader.apply(userDetailsManager, username)) + .orElseThrow(() -> new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation")); + } catch(AuthenticationServiceException ex) { + throw ex; + } catch(UsernameNotFoundException ex) { + // 用户未找到,新增用户 + try { + additionalAuthenticationChecks(null, authentication); + return addUser(username, authentication); + } catch(Exception e) { + throw new InternalAuthenticationServiceException(e.getMessage(), e); + } + } catch(Exception ex) { + throw new InternalAuthenticationServiceException(ex.getMessage(), ex); + } + } + + @Override + protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { + String username = String.valueOf(authentication.getPrincipal()); + int nonce = NumberUtils.toInt(String.valueOf(authentication.getCredentials()), -1); + for(NonceHandler nonceHandler : nonceHandlers) { + if(nonceHandler.support(username)) { + nonceHandler.check(username, nonce); + } + } + } + + public UserDetails addUser(String username, UsernamePasswordAuthenticationToken authentication) { + // 处理手机号-不存在可以注册的逻辑 + userService.addUser(username); + return retrieveUser(username, authentication); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceHandler.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..0eb18c3c3113ea625d0823f2edb6bcdc69cf518b --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceHandler.java @@ -0,0 +1,24 @@ +package cn.seqdata.oauth2.nonce; + +/** + * Author: jrxian + * Date: 2020-01-26 10:28 + */ +public interface NonceHandler { + boolean support(String username); + + /** + * 生成一次性验证码 + */ + void nonce(String username, int expire, String clientId); + + /** + * 生成一次性验证码-存入redis + */ + void nonceWithRedis(String username, int expire); + + /** + * 检查一次性验证码 + */ + void check(String username, int nonce); +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceHandlerImpl.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceHandlerImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..58c5cb02fe79e22edfbdbba09bf0b4b91d35cb80 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceHandlerImpl.java @@ -0,0 +1,152 @@ +package cn.seqdata.oauth2.nonce; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang3.RandomUtils; +import org.apache.commons.lang3.StringUtils; +import org.joda.time.DateTime; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.www.NonceExpiredException; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; + +/** + * @author jrxian + * @date 2020-06-21 21:26 + */ +public abstract class NonceHandlerImpl implements NonceHandler { + protected UserAccountRepo accountRepo; + protected StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + private static final String NONCE = "username_to_nonce:"; + + protected abstract Optional findAccount(String username); + + protected abstract UserAccount saveAccount(String username, String clientId); + + protected abstract void sendNonce(String username, int nonce); + + public NonceHandlerImpl(UserAccountRepo accountRepo, StringRedisTemplate redisTemplate) { + this.accountRepo = accountRepo; + this.redisTemplate = redisTemplate; + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) + @Override + public void nonce(String username, int expire, String clientId) { + UserAccount account = findAccount(username).orElseGet(() -> saveAccount(username, clientId)); + + int nonce = RandomUtils.nextInt(100000, 999999); + DateTime now = DateTime.now(); + account.setNonceCode(nonce); + account.setNonceExpire(now.plusSeconds(expire)); + accountRepo.save(account); + + sendNonce(username, nonce); + } + + @Transactional + @Override + public void nonceWithRedis(String username, int expire) { + int nonce = RandomUtils.nextInt(100000, 999999); + DateTime now = DateTime.now(); + now = now.plusSeconds(expire); + // key:username_to_nonce:135xxxxxxxx + redisTemplate.opsForValue() + .set(NONCE + username, toJSON(new Nonce(nonce, now.getMillis())), expire, TimeUnit.SECONDS); + sendNonce(username, nonce); + } + + @Transactional + @Override + public void check(String username, int nonce) throws AuthenticationException { + RuntimeException checkNonceAccount = checkNonceAccount(username, nonce); + if(Objects.isNull(checkNonceAccount)) { + return; + } + //redis判断验证码是否存在 + RuntimeException checkNonceRedis = checkNonceRedis(username, nonce); + if(Objects.isNull(checkNonceRedis)) { + return; + } + if(checkNonceRedis instanceof NonceExpiredException || checkNonceRedis instanceof BadCredentialsException) { + throw checkNonceRedis; + } + if(checkNonceAccount instanceof UsernameNotFoundException && checkNonceRedis instanceof AuthenticationCredentialsNotFoundException) { + throw checkNonceRedis; + } + throw checkNonceAccount; + } + + private RuntimeException checkNonceAccount(String username, int nonce) { + Optional accountOptional = findAccount(username); + if(!accountOptional.isPresent()) { + return new UsernameNotFoundException("无法用此账号登录"); + } + UserAccount account = accountOptional.get(); + try { + DateTime expire = account.getNonceExpire(); + Integer code = account.getNonceCode(); + if(Objects.isNull(expire) || Objects.isNull(code)) { + return new AuthenticationCredentialsNotFoundException("未获取验证码"); + } + if(expire.isBeforeNow()) { + return new NonceExpiredException("验证码已过期"); + } + if(nonce != code) { + return new BadCredentialsException("无效的验证码"); + } + return null; + } finally { + account.setNonceCode(null); + account.setNonceExpire(null); + accountRepo.save(account); + } + } + + private RuntimeException checkNonceRedis(String username, int nonce) { + String json = redisTemplate.opsForValue() + .get(NONCE + username); + Nonce non = fromJSON(json, Nonce.class); + if(Objects.isNull(non)) { + return new AuthenticationCredentialsNotFoundException("验证码已失效或未获取"); + } + if(!Objects.equals(nonce, non.getNonceCode())) { + return new BadCredentialsException("无效的验证码"); + } + if(non.getExpire() < System.currentTimeMillis()) { + return new NonceExpiredException("验证码已过期"); + } + return null; + } + + private String toJSON(T object) { + try { + return objectMapper.writeValueAsString(object); + } catch(JsonProcessingException ex) { + throw new RuntimeException(ex); + } + } + + private T fromJSON(String json, Class clazz) { + if(StringUtils.isBlank(json)) { + return null; + } + try { + return objectMapper.readValue(json, clazz); + } catch(JsonProcessingException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceTokenGranter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceTokenGranter.java new file mode 100644 index 0000000000000000000000000000000000000000..919631ad178f47d51b0b0b477681ca0903960635 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceTokenGranter.java @@ -0,0 +1,33 @@ +package cn.seqdata.oauth2.nonce; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.provider.ClientDetailsService; +import org.springframework.security.oauth2.provider.OAuth2RequestFactory; +import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; + +/** + * Author: jrxian + * Date: 2020-01-26 00:16 + */ +public class NonceTokenGranter extends AbstractPasswordTokenGranter { + + public NonceTokenGranter(AuthenticationProvider authenticationProvider, + AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, + OAuth2RequestFactory requestFactory, String grantType) { + super(new NonceTokenGranter.NonceAuthenticationManager(authenticationProvider), + tokenServices, clientDetailsService, requestFactory, grantType); + } + + @lombok.RequiredArgsConstructor + private static class NonceAuthenticationManager implements AuthenticationManager { + private final AuthenticationProvider authenticationProvider; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + return authenticationProvider.authenticate(authentication); + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceUserService.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceUserService.java new file mode 100644 index 0000000000000000000000000000000000000000..abb244ae479eb8ddc92cbc02eae7fe5e39c8164d --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/NonceUserService.java @@ -0,0 +1,33 @@ +package cn.seqdata.oauth2.nonce; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import cn.seqdata.oauth2.jpa.rbac.User; +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; +import cn.seqdata.oauth2.repos.rbac.UserRepo; +import lombok.AllArgsConstructor; + +/** + * @author Lin.Zhang + * @date 2021/6/28 + */ +@Service +@AllArgsConstructor +public class NonceUserService { + + private final UserAccountRepo accountRepo; + private final UserRepo userRepo; + + @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) + public void addUser(String username) { + // 处理手机号-不存在可以注册的逻辑 + User user = new User(); + user = userRepo.save(user); + UserAccount account = accountRepo.getOne(user.getId()); + account.setMobile(username); + accountRepo.save(account); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/PasswordTokenGranter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/PasswordTokenGranter.java new file mode 100644 index 0000000000000000000000000000000000000000..4dd2be67af16e152036ca8e16fd71cb8698fc69a --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/nonce/PasswordTokenGranter.java @@ -0,0 +1,38 @@ +package cn.seqdata.oauth2.nonce; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.oauth2.common.exceptions.BadClientCredentialsException; +import org.springframework.security.oauth2.provider.ClientDetailsService; +import org.springframework.security.oauth2.provider.OAuth2RequestFactory; +import org.springframework.security.oauth2.provider.TokenRequest; +import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; + +import cn.seqdata.crypto.exchanger.KeyExchanger; + +/** + * @author jrxian + * @date 2021/1/13 22:05 + */ +@Slf4j +public class PasswordTokenGranter extends AbstractPasswordTokenGranter { + private static final String GRANT_TYPE = "password"; + private final KeyExchanger keyExchanger; + + public PasswordTokenGranter(KeyExchanger keyExchanger, AuthenticationManager authenticationManager, + AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) { + super(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE); + this.keyExchanger = keyExchanger; + } + + @Override + protected String obtainPassword(TokenRequest request) { + try { + return keyExchanger.decrypt(super.obtainPassword(request)); + } catch(RuntimeException ex) { + log.debug(ex.getMessage()); + throw new BadClientCredentialsException(); + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/params/UserAccountParam.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/params/UserAccountParam.java new file mode 100644 index 0000000000000000000000000000000000000000..a81c6d8493acfc5fe2d6e5d5aee2c4b234ed00c4 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/params/UserAccountParam.java @@ -0,0 +1,13 @@ +package cn.seqdata.oauth2.params; + +@lombok.Getter +@lombok.Setter +public class UserAccountParam { + private Long id; + private String name; + private String username; + private String email; + private String mobile; + private String password; + private String nonce; +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/DefaultRestTemplateCustomizer.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/DefaultRestTemplateCustomizer.java new file mode 100644 index 0000000000000000000000000000000000000000..6366d0faf0ebb99d2bc99d6351ea8dc142114e7a --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/DefaultRestTemplateCustomizer.java @@ -0,0 +1,26 @@ +package cn.seqdata.oauth2.provider; + +import java.util.List; + +import org.springframework.boot.web.client.RestTemplateCustomizer; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +/** + * Author: jrxian + * Date: 2020-01-25 11:33 + */ +public class DefaultRestTemplateCustomizer implements RestTemplateCustomizer { + + @Override + public void customize(RestTemplate restTemplate) { + List> messageConverters = restTemplate.getMessageConverters(); + messageConverters.add(new OAuth2AccessTokenResponseHttpMessageConverter()); + messageConverters.add(new MappingJackson2HttpMessageConverter()); + + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/OAuth2Operations.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/OAuth2Operations.java new file mode 100644 index 0000000000000000000000000000000000000000..8f7bf91194ad04327b83bee0be825995bf8a4745 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/OAuth2Operations.java @@ -0,0 +1,34 @@ +package cn.seqdata.oauth2.provider; + +import java.net.URI; + +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.user.OAuth2User; + +/** + * https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/ + */ +public interface OAuth2Operations { + + /** + * GET https://github.com/login/oauth/authorize?client_id=&scope=&state=&redirect_uri= + */ + URI buildAuthorizeURI(String state, String redirectURI); + + /** + * POST https://github.com/login/oauth/access_token?client_id=&client_secret=&code=&state= + */ + OAuth2AccessTokenResponse exchangeForAccess(String code, String state); + + OAuth2AccessTokenResponse exchangeCredentialsForAccess(String username, String password); + + OAuth2AccessTokenResponse refreshAccess(OAuth2AccessToken accessToken, OAuth2RefreshToken refreshToken); + + /** + * Authorization: token OAUTH-TOKEN + * GET https://api.github.com/user + */ + OAuth2User loadUser(OAuth2AccessTokenResponse accessTokenResponse); +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/OAuth2Provider.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/OAuth2Provider.java new file mode 100644 index 0000000000000000000000000000000000000000..13c36fdf8cf163ecebf427d7c30e95854d7ca3d9 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/OAuth2Provider.java @@ -0,0 +1,105 @@ +package cn.seqdata.oauth2.provider; + +import java.util.function.Function; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import cn.seqdata.oauth2.github.GithubUserProfile; +import cn.seqdata.oauth2.wechat.WechatOAuth2Template; +import cn.seqdata.oauth2.wechat.WechatUserProfile; + +/** + * Author: jrxian + * Date: 2020-01-24 00:39 + */ +public enum OAuth2Provider { + GITHUB("github.com", GithubUserProfile::new) { + @Override + public ClientRegistration.Builder getBuilder(String registrationId) { + return CommonOAuth2Provider.GITHUB.getBuilder(registrationId); + } + }, + TENCENT("graph.qq.com", UserProfile::new) { + @Override + public ClientRegistration.Builder getBuilder(String registrationId) { + return ClientRegistration.withRegistrationId(registrationId) + .authorizationUri("https://graph.qq.com/oauth2.0/show") + .tokenUri("https://graph.qq.com/oauth2.0/token") + .userInfoUri("https://graph.qq.com/user/get_user_info"); + } + }, + WECHAT("api.weixin.qq.com", WechatUserProfile::new) { + @Override + public ClientRegistration.Builder getBuilder(String registrationId) { + return ClientRegistration.withRegistrationId(registrationId) + .authorizationUri("https://open.weixin.qq.com/connect/qrconnect") + .tokenUri("https://api.weixin.qq.com/sns/oauth2/access_token") + .userInfoUri("https://api.weixin.qq.com/sns/userinfo"); + } + + @Override + public OAuth2Template createOAuth2Template(ClientRegistration registration) { + return new WechatOAuth2Template(registration); + } + }, + ALIPAY("openapi.alipay.com", UserProfile::new) { + @Override + public ClientRegistration.Builder getBuilder(String registrationId) { + return ClientRegistration.withRegistrationId(registrationId) + .authorizationUri("https://openauth.alipay.com/oauth2/publicAppAuthorize.htm") + .tokenUri("https://openapi.alipay.com/gateway.do") + .userInfoUri("https://openapi.alipay.com/gateway.do"); + } + }, + DEFAULT("localhost", UserProfile::new) { + @Override + public ClientRegistration.Builder getBuilder(String registrationId) { + return ClientRegistration.withRegistrationId(registrationId) + .authorizationUri("https://localhost/oauth/authorize") + .tokenUri("https://localhost/oauth/token") + .userInfoUri("https://localhost/oauth/check_token"); + } + }; + + public final String hostname; + public final Function profile; + + OAuth2Provider(String hostname, Function profile) { + this.hostname = hostname; + this.profile = profile; + } + + public abstract ClientRegistration.Builder getBuilder(String registrationId); + + public OAuth2Template createOAuth2Template(ClientRegistration registration) { + return new OAuth2Template(registration); + } + + public static OAuth2Provider fromString(String providerId) { + for(OAuth2Provider value : values()) { + if(0 == StringUtils.compareIgnoreCase(value.name(), providerId)) { + return value; + } + } + + return DEFAULT; + } + + public static OAuth2Provider match(String url) { + for(OAuth2Provider value : values()) { + if(StringUtils.contains(url, value.hostname)) { + return value; + } + } + + return DEFAULT; + } + + public static OAuth2Provider provider(ClientRegistration registration) { + ClientRegistration.ProviderDetails details = registration.getProviderDetails(); + return OAuth2Provider.match(details.getTokenUri()); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/OAuth2Template.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/OAuth2Template.java new file mode 100644 index 0000000000000000000000000000000000000000..b716021a76b98ab8364d8d3b218d9d60502ab724 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/OAuth2Template.java @@ -0,0 +1,141 @@ +package cn.seqdata.oauth2.provider; + +import java.net.URI; +import java.util.Collections; +import java.util.Objects; + +import org.springframework.boot.web.client.RestTemplateCustomizer; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.RequestEntity; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.security.oauth2.client.endpoint.*; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.endpoint.*; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Author: jrxian + * Date: 2020-01-24 09:29 + */ +public class OAuth2Template implements OAuth2Operations { + protected final ClientRegistration registration; + protected final RestTemplate restTemplate = createRestTemplate(); + private Converter> authorizationCodeEntityConverter; + private Converter> passwordEntityConverter; + private Converter> refreshTokenEntityConverter; + + public OAuth2Template(ClientRegistration registration) { + this(registration, new DefaultRestTemplateCustomizer()); + } + + public OAuth2Template(ClientRegistration registration, RestTemplateCustomizer restTemplateCustomizer) { + this.registration = registration; + restTemplateCustomizer.customize(restTemplate); + } + + @Override + public URI buildAuthorizeURI(String state, String redirectURI) { + ClientRegistration.ProviderDetails providerDetails = registration.getProviderDetails(); + + return UriComponentsBuilder.fromUriString(providerDetails.getAuthorizationUri()) + .queryParam(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2ParameterNames.CODE) + .queryParam(OAuth2ParameterNames.CLIENT_ID, registration.getClientId()) + .queryParam(OAuth2ParameterNames.SCOPE, registration.getScopes()) + .queryParam(OAuth2ParameterNames.STATE, state) + .queryParam(OAuth2ParameterNames.REDIRECT_URI, redirectURI) + .build() + .toUri(); + } + + @Override + public OAuth2AccessTokenResponse exchangeForAccess(String code, String state) { + DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient(); + client.setRestOperations(restTemplate); + + if(Objects.nonNull(authorizationCodeEntityConverter)) { + client.setRequestEntityConverter(authorizationCodeEntityConverter); + } + + OAuth2AuthorizationRequest.Builder requestBuilder = buildOAuth2AuthorizationRequest(state); + OAuth2AuthorizationResponse.Builder responseBuilder = buildOAuth2AuthorizationResponse(code, state); + + OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(requestBuilder.build(), responseBuilder.build()); + OAuth2AuthorizationCodeGrantRequest request = new OAuth2AuthorizationCodeGrantRequest(registration, exchange); + return client.getTokenResponse(request); + } + + @Override + public OAuth2AccessTokenResponse exchangeCredentialsForAccess(String username, String password) { + DefaultPasswordTokenResponseClient client = new DefaultPasswordTokenResponseClient(); + client.setRestOperations(restTemplate); + + if(Objects.nonNull(passwordEntityConverter)) { + client.setRequestEntityConverter(passwordEntityConverter); + } + + OAuth2PasswordGrantRequest request = new OAuth2PasswordGrantRequest(registration, username, password); + return client.getTokenResponse(request); + } + + @Override + public OAuth2AccessTokenResponse refreshAccess(OAuth2AccessToken accessToken, OAuth2RefreshToken refreshToken) { + DefaultRefreshTokenTokenResponseClient client = new DefaultRefreshTokenTokenResponseClient(); + client.setRestOperations(restTemplate); + + if(Objects.nonNull(refreshTokenEntityConverter)) { + client.setRequestEntityConverter(refreshTokenEntityConverter); + } + + OAuth2RefreshTokenGrantRequest request = new OAuth2RefreshTokenGrantRequest(registration, accessToken, refreshToken); + return client.getTokenResponse(request); + } + + @Override + public OAuth2User loadUser(OAuth2AccessTokenResponse accessTokenResponse) { + DefaultOAuth2UserService client = new DefaultOAuth2UserService(); + client.setRestOperations(restTemplate); + + OAuth2UserRequest userRequest = new OAuth2UserRequest(registration, accessTokenResponse.getAccessToken()); + return client.loadUser(userRequest); + } + + public void setAuthorizationCodeEntityConverter(Converter> authorizationCodeEntityConverter) { + this.authorizationCodeEntityConverter = authorizationCodeEntityConverter; + } + + public void setPasswordEntityConverter(Converter> passwordEntityConverter) { + this.passwordEntityConverter = passwordEntityConverter; + } + + public void setRefreshTokenEntityConverter(Converter> refreshTokenEntityConverter) { + this.refreshTokenEntityConverter = refreshTokenEntityConverter; + } + + protected RestTemplate createRestTemplate() { + return new RestTemplate(Collections.singletonList(new FormHttpMessageConverter())); + } + + protected OAuth2AuthorizationRequest.Builder buildOAuth2AuthorizationRequest(String state) { + ClientRegistration.ProviderDetails providerDetails = registration.getProviderDetails(); + + return OAuth2AuthorizationRequest + .authorizationCode() + .clientId(registration.getClientId()) + .scopes(registration.getScopes()) + .state(state) + .authorizationUri(providerDetails.getAuthorizationUri()); + } + + protected OAuth2AuthorizationResponse.Builder buildOAuth2AuthorizationResponse(String code, String state) { + return OAuth2AuthorizationResponse + .success(code) + .state(state) + .redirectUri(registration.getRedirectUriTemplate()); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/UserProfile.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/UserProfile.java new file mode 100644 index 0000000000000000000000000000000000000000..1eace87a32b8b0dee9f4484bbd7a8d278c1ec0f0 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/provider/UserProfile.java @@ -0,0 +1,94 @@ +package cn.seqdata.oauth2.provider; + +import java.io.Serializable; +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; + +import cn.seqdata.oauth2.OAuth2Constants; + +/** + * Author: jrxian + * Date: 2020-01-23 23:26 + */ +@lombok.Getter +@lombok.Setter +public class UserProfile implements OAuth2User, OAuth2Constants, Serializable { + private final OAuth2User delegate; + + public UserProfile(OAuth2User delegate) { + this.delegate = delegate; + } + + /** + * 唯一编号或者唯一名称 + */ + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public A getAttribute(String name) { + return delegate.getAttribute(name); + } + + @JsonAnySetter + public void setAttribute(String key, Object value) { + getAttributes().put(key, value); + } + + @JsonAnyGetter + @Override + public Map getAttributes() { + return delegate.getAttributes(); + } + + @Override + public Collection getAuthorities() { + return delegate.getAuthorities(); + } + + /** + * 登录名 + */ + public String getUsername() { + return getAttribute(USERNAME); + } + + /** + * 邮箱 + */ + public String getEmail() { + return getAttribute(EMAIL); + } + + public String getMobile() { + return getAttribute(MOBILE); + } + + /** + * 昵称 + */ + public String getNickname() { + return getAttribute("nickname"); + } + + /** + * 头像 + */ + public String getAvatar() { + return getAttribute("avatar"); + } + + /** + * 地址 + */ + public String getLocation() { + return getAttribute("location"); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/AccountLockService.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/AccountLockService.java new file mode 100644 index 0000000000000000000000000000000000000000..d26891193705f53ff5e90a0c0077dac9ebd4d8a4 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/AccountLockService.java @@ -0,0 +1,34 @@ +package cn.seqdata.oauth2.service; + +import org.joda.time.DateTime; +import org.springframework.stereotype.Service; + +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; +import cn.seqdata.syslog.Syslog; + +/** + * @author jrxian + * @date 2020/12/14 1:00 + */ +@Service +@lombok.AllArgsConstructor +public class AccountLockService { + private final UserAccountRepo accountRepo; + + @Syslog + public void lock(String username, DateTime lockExpireDate) { + accountRepo.findByUsername(username) + .ifPresent(account -> { + account.setLockExpireDate(lockExpireDate); + accountRepo.save(account); + }); + } + + public void unlock(String username) { + accountRepo.findByUsername(username) + .ifPresent(account -> { + account.setLockExpireDate(null); + accountRepo.save(account); + }); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/DefaultTokenEnhancer.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/DefaultTokenEnhancer.java new file mode 100644 index 0000000000000000000000000000000000000000..4adc99a3a5142f475ebefcf79833fbb8afa90dcd --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/DefaultTokenEnhancer.java @@ -0,0 +1,49 @@ +package cn.seqdata.oauth2.service; + +import java.util.HashMap; +import java.util.Map; +import lombok.AllArgsConstructor; + +import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.token.TokenEnhancer; + +import cn.seqdata.oauth2.OAuth2Constants; +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import cn.seqdata.oauth2.repos.rbac.UserRepo; +import cn.seqdata.oauth2.util.SecurityUtils; + +/** + * @author jrxian + * @date 2020-09-24 14:55 + */ +@AllArgsConstructor +public class DefaultTokenEnhancer implements TokenEnhancer { + private final UserRepo userRepo; + private final UserService userService; + + @Override + public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { + if(accessToken instanceof DefaultOAuth2AccessToken) { + Map attributes = new HashMap<>(); + + String grantType = SecurityUtils.grantType(authentication); + String username = SecurityUtils.username(authentication); + userService.loadUser(grantType, username) + .map(UserAccount::getId) + .map(userRepo::getOne) + .ifPresent(user -> { + attributes.put(OAuth2Constants.NAME, user.getName()); + attributes.put(OAuth2Constants.USER_ID, user.getId()); + attributes.put(OAuth2Constants.POST_ID, user.getPostId()); + attributes.put(OAuth2Constants.DEPT_ID, user.getDeptId()); + attributes.put(OAuth2Constants.ORG_ID, user.getOrgId()); + }); + + ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(attributes); + } + + return accessToken; + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/DefaultTokenServices.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/DefaultTokenServices.java new file mode 100644 index 0000000000000000000000000000000000000000..a2fd6ce9c31a621be8aec4c0437985e60d8e99f5 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/DefaultTokenServices.java @@ -0,0 +1,170 @@ +package cn.seqdata.oauth2.service; + +import java.util.Date; +import java.util.Objects; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.RandomStringUtils; +import org.joda.time.DateTimeConstants; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.common.*; +import org.springframework.security.oauth2.common.exceptions.InvalidGrantException; +import org.springframework.security.oauth2.common.exceptions.InvalidTokenException; +import org.springframework.security.oauth2.provider.*; +import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; +import org.springframework.security.oauth2.provider.token.TokenEnhancer; +import org.springframework.security.oauth2.provider.token.TokenStore; + +import lombok.RequiredArgsConstructor; + +/** + * @author jrxian + * @date 2020-11-23 16:49 + */ +@RequiredArgsConstructor +public class DefaultTokenServices implements AuthorizationServerTokenServices { + private boolean supportRefreshToken; + private int accessTokenValiditySeconds = 30 * DateTimeConstants.SECONDS_PER_MINUTE; + private int refreshTokenValiditySeconds = 30 * DateTimeConstants.SECONDS_PER_DAY; + + private final ClientDetailsService clientDetailsService; + private final TokenStore tokenStore; + private final TokenEnhancer tokenEnhancer; + + public boolean isSupportRefreshToken() { + return supportRefreshToken; + } + + public void setSupportRefreshToken(boolean supportRefreshToken) { + this.supportRefreshToken = supportRefreshToken; + } + + public int getAccessTokenValiditySeconds() { + return accessTokenValiditySeconds; + } + + public void setAccessTokenValiditySeconds(int accessTokenValiditySeconds) { + this.accessTokenValiditySeconds = accessTokenValiditySeconds; + } + + public int getRefreshTokenValiditySeconds() { + return refreshTokenValiditySeconds; + } + + public void setRefreshTokenValiditySeconds(int refreshTokenValiditySeconds) { + this.refreshTokenValiditySeconds = refreshTokenValiditySeconds; + } + + @Override + public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) { + return tokenStore.getAccessToken(authentication); + } + + @Override + public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException { + OAuth2RefreshToken refreshToken = createRefreshToken(authentication); + OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken); + tokenStore.storeAccessToken(accessToken, authentication); + return accessToken; + } + + public void refreshAccessTokenAuthorities(OAuth2Authentication oldAuthentication, + OAuth2Authentication newAuthentication) { + OAuth2AccessToken origAccessToken = getAccessToken(oldAuthentication); + // 设置origAccessToken部分参数更新,增强等,未做 + tokenStore.storeAccessToken(origAccessToken, newAuthentication); + } + + @Override + public OAuth2AccessToken refreshAccessToken(String refreshTokenValue, TokenRequest tokenRequest) throws AuthenticationException { + if(!supportRefreshToken) { + throw new InvalidGrantException("Invalid refresh token: " + refreshTokenValue); + } + + OAuth2RefreshToken refreshToken = tokenStore.readRefreshToken(refreshTokenValue); + if(Objects.isNull(refreshToken)) { + throw new InvalidGrantException("Invalid refresh token: " + refreshTokenValue); + } + + tokenStore.removeAccessTokenUsingRefreshToken(refreshToken); + + if(isExpired(refreshToken)) { + tokenStore.removeRefreshToken(refreshToken); + throw new InvalidTokenException("Invalid refresh token (expired): " + refreshToken); + } + + OAuth2Authentication authentication = tokenStore.readAuthenticationForRefreshToken(refreshToken); + OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken); + tokenStore.storeAccessToken(accessToken, authentication); + + return accessToken; + } + + private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) { + DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(createTokenValue()); + + token.setExpiration(expiration(accessTokenValiditySeconds(authentication))); + token.setRefreshToken(refreshToken); + OAuth2Request auth2Request = authentication.getOAuth2Request(); + token.setScope(auth2Request.getScope()); + + return Objects.nonNull(tokenEnhancer) ? tokenEnhancer.enhance(token, authentication) : token; + } + + private OAuth2RefreshToken createRefreshToken(OAuth2Authentication authentication) { + if(!supportRefreshToken) return null; + return new DefaultExpiringOAuth2RefreshToken(createTokenValue(), expiration(refreshTokenValiditySeconds(authentication))); + } + + private String createTokenValue() { + return String.format("%08x", (int) (System.currentTimeMillis() / 1000)) + RandomStringUtils.randomAlphanumeric(8); + } + + private String clientId(OAuth2Authentication authentication) { + OAuth2Request request = authentication.getOAuth2Request(); + return request.getClientId(); + } + + /** + * 获取accessToken有效时间,先查client配置,没有的话用默认时间代替 + */ + private int accessTokenValiditySeconds(OAuth2Authentication authentication) { + try { + ClientDetails client = clientDetailsService.loadClientByClientId(clientId(authentication)); + return ObjectUtils.defaultIfNull(client.getAccessTokenValiditySeconds(), accessTokenValiditySeconds); + } catch(ClientRegistrationException ex) { + return accessTokenValiditySeconds; + } + } + + /** + * 获取refreshToken有效时间,先查client配置,没有的话用默认时间代替 + */ + private int refreshTokenValiditySeconds(OAuth2Authentication authentication) { + try { + ClientDetails client = clientDetailsService.loadClientByClientId(clientId(authentication)); + return ObjectUtils.defaultIfNull(client.getRefreshTokenValiditySeconds(), refreshTokenValiditySeconds); + } catch(ClientRegistrationException ex) { + return refreshTokenValiditySeconds; + } + } + + /** + * 当前系统时间加上秒数,得到过期时间 + */ + private Date expiration(int validitySeconds) { + return new Date(System.currentTimeMillis() + (long) validitySeconds * DateTimeConstants.MILLIS_PER_SECOND); + } + + /** + * 判断refreshToken是否过期 + */ + private boolean isExpired(OAuth2RefreshToken refreshToken) { + if(refreshToken instanceof ExpiringOAuth2RefreshToken) { + ExpiringOAuth2RefreshToken expiringToken = (ExpiringOAuth2RefreshToken) refreshToken; + Date expiration = expiringToken.getExpiration(); + return expiration == null || System.currentTimeMillis() > expiration.getTime(); + } + return false; + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/PermissionPredicate.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/PermissionPredicate.java new file mode 100644 index 0000000000000000000000000000000000000000..ec13e921536b72128006c32abc25cab6f75a0274 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/PermissionPredicate.java @@ -0,0 +1,51 @@ +package cn.seqdata.oauth2.service; + +import java.security.Principal; +import java.util.Collections; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import cn.seqdata.jpa.JpaIdGenerated; +import cn.seqdata.oauth2.DefaultRole; +import cn.seqdata.oauth2.domain.PermissionType; +import cn.seqdata.oauth2.jpa.perm.JpaPermission; +import cn.seqdata.oauth2.jpa.rbac.AuthorityPermission; +import cn.seqdata.oauth2.repos.rbac.AuthorityPermissionRepo; +import cn.seqdata.oauth2.util.SecurityUtils; + +/** + * @author jrxian + * @date 2020-05-08 20:21 + */ +public class PermissionPredicate implements Predicate { + private final boolean sysAdmin; + private final Set permissions; + + public PermissionPredicate(AuthorityPermissionRepo permissionRepo, PermissionType permType, Principal principal) { + Set authorities = SecurityUtils.authorities(principal); + sysAdmin = authorities.contains(DefaultRole.sysadmin.name()); + if(sysAdmin) { + permissions = Collections.emptySet(); + } else { + permissions = fetchPermissions(permissionRepo, permType, authorities); + } + } + + @Override + public boolean test(JpaPermission permission) { + //系统管理员,模块不需要权限,是否包含所需权限 + return sysAdmin || permissions.contains(permission.getId()); + } + + /** + * 根据角色列表获取所有可操作的权限 + */ + private Set fetchPermissions(AuthorityPermissionRepo permissionRepo, PermissionType permType, Set authorities) { + return permissionRepo.findByAuthorityIdentifierInAndPermissionType(authorities, permType) + .stream() + .map(AuthorityPermission::getPermission) + .map(JpaIdGenerated::getId) + .collect(Collectors.toSet()); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/RedisTokenStore.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/RedisTokenStore.java new file mode 100644 index 0000000000000000000000000000000000000000..e5863150ee2226787006dcb7f54f18c98e9c3bef --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/RedisTokenStore.java @@ -0,0 +1,347 @@ +package cn.seqdata.oauth2.service; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.connection.RedisZSetCommands; +import org.springframework.data.redis.core.*; +import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.common.DefaultOAuth2RefreshToken; +import org.springframework.security.oauth2.common.ExpiringOAuth2RefreshToken; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.common.OAuth2RefreshToken; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.OAuth2Request; +import org.springframework.security.oauth2.provider.token.TokenStore; +import org.springframework.util.CollectionUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +import cn.seqdata.oauth2.jackson.OAuth2AuthenticationBean; +import cn.seqdata.oauth2.jackson.OAuth2Module; + +/** + * @author jrxian + * @date 2020-11-15 15:48 + */ +public class RedisTokenStore implements TokenStore { + private static final String ACCESS = "access:"; + private static final String ACCESS_AUTH = "access_auth:"; + private static final String CLIENTID_TO_ACCESS = "clientid_to_access:"; + private static final String USERNAME_TO_ACCESS = "username_to_access:"; + private static final String REFRESH = "refresh:"; + private static final String REFRESH_AUTH = "refresh_auth:"; + private static final String ACCESS_TO_REFRESH = "access_to_refresh:"; + private static final String REFRESH_TO_ACCESS = "refresh_to_access:"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final StringRedisTemplate redisTemplate; + private final ValueOperations opsForValue; + private final ZSetOperations opsForZSet; + + //每个用户能登录的最大并发连接数,<=0表示不限制 + private int maximumSessions; + + public RedisTokenStore(StringRedisTemplate redisTemplate) { + this.objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + this.objectMapper.registerModule(new OAuth2Module()); + + this.redisTemplate = redisTemplate; + this.opsForValue = redisTemplate.opsForValue(); + this.opsForZSet = redisTemplate.opsForZSet(); + } + + public int getMaximumSessions() { + return maximumSessions; + } + + public void setMaximumSessions(int maximumSessions) { + this.maximumSessions = maximumSessions; + } + + @Override + public OAuth2Authentication readAuthentication(String token) { + return fromJSON(opsForValue.get(ACCESS_AUTH + token)); + } + + @Override + public OAuth2Authentication readAuthentication(OAuth2AccessToken token) { + return readAuthentication(token.getValue()); + } + + @Override + public OAuth2AccessToken readAccessToken(String tokenValue) { + OAuth2AccessToken accessToken = fromJSON(opsForValue.get(ACCESS + tokenValue), OAuth2AccessToken.class); + + //每次访问时自动续期,防止token失效 + if(Objects.nonNull(accessToken)) { + int expiresIn = accessToken.getExpiresIn(); + if(expiresIn > 0) { + redisTemplate.executePipelined(new SessionCallback() { + @Override + public Object execute(@NonNull RedisOperations operations) throws DataAccessException { + redisTemplate.expire(ACCESS + tokenValue, expiresIn, TimeUnit.SECONDS); + redisTemplate.expire(ACCESS_AUTH + tokenValue, expiresIn, TimeUnit.SECONDS); + return null; + } + }); + } + } + return accessToken; + } + + @Override + public OAuth2AccessToken getAccessToken(OAuth2Authentication authentication) { + ZSetOperations opsForZSet = redisTemplate.opsForZSet(); + String key = USERNAME_TO_ACCESS + getApprovalKey(authentication); + //找到最后一次生成的token + Set> tuples = opsForZSet.reverseRangeWithScores(key, 0, 0); + ZSetOperations.TypedTuple tuple = CollectionUtils.lastElement(tuples); + if(Objects.nonNull(tuple)) { + return readAccessToken(tuple.getValue()); + } else { + return null; + } + } + + @Override + public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) { + redisTemplate.execute(new SessionCallback() { + @Override + public Object execute(@NonNull RedisOperations operations) throws DataAccessException { + OAuth2Request auth2Request = authentication.getOAuth2Request(); + String tokenValue = token.getValue(); + int expires = token.getExpiresIn(); + opsForValue.set(ACCESS + tokenValue, toJSON(token), expires, TimeUnit.SECONDS); + opsForValue.set(ACCESS_AUTH + tokenValue, toJSON(authentication), expires, TimeUnit.SECONDS); + if(!authentication.isClientOnly()) { + String key = USERNAME_TO_ACCESS + getApprovalKey(authentication); + opsForZSet.add(key, tokenValue, System.currentTimeMillis()); + checkMaximumSessions(key); + } else { + String key = CLIENTID_TO_ACCESS + auth2Request.getClientId(); + opsForZSet.add(key, tokenValue, System.currentTimeMillis()); + } + + OAuth2RefreshToken refreshToken = token.getRefreshToken(); + if(Objects.nonNull(refreshToken) && Objects.nonNull(refreshToken.getValue())) { + storeRefreshToken(refreshToken, authentication); + + String refreshToAccessKey = REFRESH_TO_ACCESS + refreshToken.getValue(); + String accessToRefreshKey = ACCESS_TO_REFRESH + tokenValue; + + opsForValue.set(refreshToAccessKey, tokenValue); + opsForValue.set(accessToRefreshKey, refreshToken.getValue()); + if(refreshToken instanceof ExpiringOAuth2RefreshToken) { + Date expiration = ((ExpiringOAuth2RefreshToken) refreshToken).getExpiration(); + redisTemplate.expireAt(refreshToAccessKey, expiration); + redisTemplate.expireAt(accessToRefreshKey, expiration); + } + } + return null; + } + }); + } + + @Override + public void removeAccessToken(OAuth2AccessToken token) { + redisTemplate.delete(ACCESS + token.getValue()); + } + + @Override + public OAuth2RefreshToken readRefreshToken(String tokenValue) { + return fromJSON(opsForValue.get(REFRESH + tokenValue), DefaultOAuth2RefreshToken.class); + } + + @Override + public void storeRefreshToken(OAuth2RefreshToken refreshToken, OAuth2Authentication authentication) { + redisTemplate.execute(new SessionCallback() { + @Override + public Object execute(@NonNull RedisOperations operations) throws DataAccessException { + String refreshKey = REFRESH + refreshToken.getValue(); + String refreshAuthKey = REFRESH_AUTH + refreshToken.getValue(); + + opsForValue.set(refreshKey, toJSON(refreshToken)); + opsForValue.set(refreshAuthKey, toJSON(authentication)); + if(refreshToken instanceof ExpiringOAuth2RefreshToken) { + Date expiration = ((ExpiringOAuth2RefreshToken) refreshToken).getExpiration(); + redisTemplate.expireAt(refreshKey, expiration); + redisTemplate.expireAt(refreshAuthKey, expiration); + } + return null; + } + }); + } + + @Override + public OAuth2Authentication readAuthenticationForRefreshToken(OAuth2RefreshToken token) { + return fromJSON(opsForValue.get(REFRESH_AUTH + token)); + } + + @Override + public void removeRefreshToken(OAuth2RefreshToken token) { + redisTemplate.delete(REFRESH + token.getValue()); + } + + @Override + public void removeAccessTokenUsingRefreshToken(OAuth2RefreshToken refreshToken) { + String refreshToAccessKey = REFRESH_TO_ACCESS + refreshToken.getValue(); + String tokenValue = opsForValue.get(refreshToAccessKey); + if(BooleanUtils.isTrue(redisTemplate.delete(refreshToAccessKey))) { + redisTemplate.delete(ACCESS + tokenValue); + } + } + + @Override + public Collection findTokensByClientId(String clientId) { + return findTokens(CLIENTID_TO_ACCESS + clientId); + } + + @Override + public Collection findTokensByClientIdAndUserName(String clientId, String userName) { + return findTokens(USERNAME_TO_ACCESS + getApprovalKey(clientId, userName)); + } + + private Collection findTokens(String key) { + List accessTokens = new LinkedList<>(); + + ZSetOperations opsForZSet = redisTemplate.opsForZSet(); + + Set tokenValues = opsForZSet.rangeByLex(key, RedisZSetCommands.Range.unbounded()); + if(!CollectionUtils.isEmpty(tokenValues)) { + tokenValues.forEach(tokenValue -> { + OAuth2AccessToken accessToken = readAccessToken(tokenValue); + if(Objects.nonNull(accessToken)) { + accessTokens.add(accessToken); + } else { + //没有找到token,说明已经失效,从列表中删除 + opsForZSet.remove(key, tokenValue); + } + }); + } + + return accessTokens; + } + + /*** + * 检查最大并发会话数限制,当超过最大并发回话时,删除最先申请的token + * @param key username_to_access:clientid:username + */ + private void checkMaximumSessions(String key) { + SortedMap tokenMap = new TreeMap<>(); + + ZSetOperations opsForZSet = redisTemplate.opsForZSet(); + Set> tuples = opsForZSet.rangeWithScores(key, 0, -1); + + //检查不存在的accessToken,先清除 + if(!CollectionUtils.isEmpty(tuples)) { + //一次性查出所有的key是否存在 + List hasKeys = redisTemplate.executePipelined(new SessionCallback() { + @Override + public Object execute(@NonNull RedisOperations operations) throws DataAccessException { + tuples.forEach(tuple -> { + String tokenValue = ObjectUtils.defaultIfNull(tuple.getValue(), String.valueOf(UUID.randomUUID())); + redisTemplate.hasKey(ACCESS + tokenValue); + }); + return null; + } + }); + + redisTemplate.execute(new SessionCallback() { + @Override + public Object execute(@NonNull RedisOperations operations) throws DataAccessException { + //不存在的key,全部删除 + Iterator> i_ = tuples.iterator(); + Iterator j_ = hasKeys.iterator(); + while(i_.hasNext() && j_.hasNext()) { + ZSetOperations.TypedTuple tuple = i_.next(); + String tokenValue = tuple.getValue(); + Object hasKey = j_.next(); + + if(hasKey instanceof Boolean && BooleanUtils.isNotTrue((Boolean) hasKey)) { + //accessToken已经不存在了,从清单中删除 + i_.remove(); + if(Objects.nonNull(tokenValue)) { + opsForZSet.remove(key, tokenValue); + } + } else { + if(Objects.nonNull(tuple.getScore())) { + tokenMap.put(tuple.getScore(), tokenValue); + } + } + } + return null; + } + }); + } + + //对现存的accessToken排序,超过最大并发会话数限制的,直接删除 + List> tokenTuples = new ArrayList<>(tokenMap.entrySet()); + if(maximumSessions > 0) { + //删除最旧的token + redisTemplate.execute(new SessionCallback() { + @Override + public Object execute(@NonNull RedisOperations operations) throws DataAccessException { + for(int i = 0; i < tokenTuples.size() - maximumSessions; ++i) { + Map.Entry entry = tokenTuples.get(i); + String tokenValue = entry.getValue(); + opsForZSet.remove(key, tokenValue); + redisTemplate.delete(ACCESS + tokenValue); + redisTemplate.delete(ACCESS_AUTH + tokenValue); + } + return null; + } + }); + } + } + + private T fromJSON(String json, Class clazz) { + if(Objects.isNull(json)) { + return null; + } + + try { + return objectMapper.readValue(json, clazz); + } catch(JsonProcessingException ex) { + throw new RuntimeException(ex); + } + } + + private String toJSON(T object) { + try { + return objectMapper.writeValueAsString(object); + } catch(JsonProcessingException ex) { + throw new RuntimeException(ex); + } + } + + /** + * 由于多态和构造参数的原因,无法直接在OAuth2Authentication和json之间互相转换 + */ + private OAuth2Authentication fromJSON(String json) { + OAuth2AuthenticationBean bean = fromJSON(json, OAuth2AuthenticationBean.class); + return Objects.nonNull(bean) ? bean.get() : null; + } + + private String toJSON(OAuth2Authentication object) { + OAuth2AuthenticationBean bean = new OAuth2AuthenticationBean(); + bean.accept(object); + return toJSON(bean); + } + + private static String getApprovalKey(OAuth2Authentication authentication) { + OAuth2Request request = authentication.getOAuth2Request(); + Authentication userAuthentication = authentication.getUserAuthentication(); + String userName = userAuthentication == null ? "" : userAuthentication.getName(); + return getApprovalKey(request.getClientId(), userName); + } + + private static String getApprovalKey(String clientId, String userName) { + return clientId + (userName == null ? "" : ":" + userName); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/RouterService.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/RouterService.java new file mode 100644 index 0000000000000000000000000000000000000000..ff16eddd01ccc6b54b77c6063db5aabc99c9dea4 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/RouterService.java @@ -0,0 +1,123 @@ +package cn.seqdata.oauth2.service; + +import java.security.Principal; +import java.util.Collection; +import java.util.Objects; +import java.util.function.BiPredicate; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; + +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import com.google.common.collect.Multimap; + +import cn.seqdata.antd.Router; +import cn.seqdata.oauth2.domain.PermissionType; +import cn.seqdata.oauth2.jpa.perm.ActionPermission; +import cn.seqdata.oauth2.jpa.perm.JpaPermission; +import cn.seqdata.oauth2.jpa.perm.ModulePermission; +import cn.seqdata.oauth2.repos.perm.ActionPermRepo; +import cn.seqdata.oauth2.repos.perm.JpaPermRepository; +import cn.seqdata.oauth2.repos.perm.ModulePermRepo; +import cn.seqdata.oauth2.repos.perm.SysPermRepo; +import cn.seqdata.oauth2.repos.rbac.AuthorityPermissionRepo; +import cn.seqdata.oauth2.util.RouterUtils; +import cn.seqdata.oauth2.util.SecurityUtils; + +/** + * @author jrxian + * @date 2020-08-29 10:17 + */ +@Service +@AllArgsConstructor +public class RouterService { + private final SysPermRepo sysPermRepo; + private final ModulePermRepo modulePermRepo; + private final ActionPermRepo actionPermRepo; + private final AuthorityPermissionRepo permissionRepo; + + /** + * 所有菜单 + */ + public Collection fetchModulePerms(Principal principal, Sort sort) { + return fetchPermissions(modulePermRepo, PermissionType.module, principal, sort); + } + + /** + * 所有按钮 + */ + public Collection fetchActionPerms(Principal principal, Sort sort) { + return fetchPermissions(actionPermRepo, PermissionType.action, principal, sort); + } + + private Collection fetchPermissions(JpaPermRepository repository, + PermissionType permType, Principal principal, Sort sort) { + boolean sysAdmin = SecurityUtils.isSysAdmin(principal); + return repository.findAll(sort) + .stream() + .filter(x -> sysAdmin || BooleanUtils.isNotFalse(x.getVisible())) + .filter(new PermissionPredicate(permissionRepo, permType, principal)) + .collect(Collectors.toList()); + } + + public Collection topRouters(Principal principal, Sort sort) { + return sysPermRepo.findAll(sort) + .stream() + .filter(x -> BooleanUtils.isNotFalse(x.getVisible())) + .filter(new PermissionPredicate(permissionRepo, PermissionType.sys, principal)) + .map(RouterUtils::toRouter) + .collect(Collectors.toList()); + } + + public Collection childRouters(Router parent, Principal principal, Sort sort) { + return modulePermRepo.findBySysPermId(toLong(parent.key), sort) + .stream() + .filter(x -> BooleanUtils.isNotFalse(x.getVisible())) + .filter(new PermissionPredicate(permissionRepo, PermissionType.module, principal)) + .map(modulePerm -> RouterUtils.toRouter(parent, modulePerm)) + .collect(Collectors.toList()); + } + + public void doRouter(Router router, Collection modulePerms, Multimap actionPerms) { + doNestedRouter(router, this::topRouterFilter, modulePerms, actionPerms); + } + + /** + * 顶层路由,顶层路由和下级路由最大的区别是顶层按sysId和parent为空过滤 + */ + private void doNestedRouter(Router router, BiPredicate predicate, + Collection modulePerms, Multimap actionPerms) { + router.meta.actions.addAll(actionPerms.get(router.meta.view)); + modulePerms.stream() + .filter(modulePerm -> predicate.test(router, modulePerm)) + .forEach(modulePerm -> { + Router childRouter = RouterUtils.toRouter(router, modulePerm); + router.children.add(childRouter); + doNestedRouter(childRouter, this::chdRouterFilter, modulePerms, actionPerms); + }); + } + + /** + * 顶层路由过滤规则,按sysId和parent为空过滤 + */ + private boolean topRouterFilter(Router router, ModulePermission modulePerm) { + return Objects.isNull(modulePerm.getParentId()) + && Objects.equals(toLong(router.key), modulePerm.getSysPermId()); + } + + /** + * 普通路由过滤规则,按parent过滤 + */ + private boolean chdRouterFilter(Router router, ModulePermission modulePerm) { + return Objects.equals(toLong(router.key), modulePerm.getParentId()); + } + + private static Long toLong(Object key) { + if(key instanceof Number) { + return ((Number) key).longValue(); + } else { + return 0L; + } + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/TokenService.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/TokenService.java new file mode 100644 index 0000000000000000000000000000000000000000..4b9af1ce48ab0e3df989ea1df0e7e2ff206e5631 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/TokenService.java @@ -0,0 +1,71 @@ +package cn.seqdata.oauth2.service; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.OAuth2Request; +import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; +import org.springframework.stereotype.Service; + +import cn.seqdata.oauth2.DefaultRole; +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import lombok.AllArgsConstructor; + +/** + * Author: jrxian + * Date: 2020-04-07 10:11 + */ +@Service +@AllArgsConstructor +public class TokenService { + private final AuthorizationServerTokenServices tokenServices; + private final UserService userService; + private final AuthorityService authorityService; + + /** + * 为第三方认证的用户创建本系统的 token + */ + public OAuth2AccessToken createToken(String clientId, OAuth2User principal) { + OAuth2Authentication authentication = createOAuth2Authentication(clientId, principal); + if(SecurityContextHolder.getContext() + .getAuthentication() instanceof AnonymousAuthenticationToken) { + SecurityContextHolder.getContext() + .setAuthentication(authentication); + } + return tokenServices.createAccessToken(authentication); + } + + public OAuth2Authentication createOAuth2Authentication(String clientId, OAuth2User principal) { + Collection authorities = loadAuthorities(clientId, principal.getName()); + OAuth2Request oAuth2Request = createOAuth2Request(clientId, authorities); + OAuth2AuthenticationToken authenticationToken = new OAuth2AuthenticationToken(principal, authorities, clientId); + return new OAuth2Authentication(oAuth2Request, authenticationToken); + } + + public Collection loadAuthorities(String clientId, String username) { + return userService.loadUser(clientId, username) + .map(UserAccount::getId) + .map(authorityService::loadAuthorities) + .orElse(DefaultRole.NO_ROLES); + } + + public static OAuth2Request createOAuth2Request(String clientId, Collection authorities) { + return new OAuth2Request(Collections.emptyMap(), + clientId, + authorities, + true, + Collections.emptySet(), + Collections.emptySet(), + null, + Collections.emptySet(), + Collections.emptyMap() + ); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/UserService.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/UserService.java new file mode 100644 index 0000000000000000000000000000000000000000..fd60ecc89bd09b772f98f8e7ca5ccc330b056b15 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/service/UserService.java @@ -0,0 +1,248 @@ +package cn.seqdata.oauth2.service; + +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import cn.seqdata.oauth2.OAuth2Constants; +import cn.seqdata.oauth2.jpa.oauth.RegistrationAuthorityId; +import cn.seqdata.oauth2.jpa.oauth.UserDetail; +import cn.seqdata.oauth2.jpa.oauth.UserDetailId; +import cn.seqdata.oauth2.jpa.rbac.User; +import cn.seqdata.oauth2.jpa.rbac.UserAccount; +import cn.seqdata.oauth2.jpa.rbac.UserAuthority; +import cn.seqdata.oauth2.provider.UserProfile; +import cn.seqdata.oauth2.repos.oauth.RegistrationAuthorityRepo; +import cn.seqdata.oauth2.repos.oauth.UserDetailRepo; +import cn.seqdata.oauth2.repos.rbac.UserAccountRepo; +import cn.seqdata.oauth2.repos.rbac.UserAuthorityRepo; +import cn.seqdata.oauth2.repos.rbac.UserRepo; +import cn.seqdata.oauth2.util.SecurityUtils; +import cn.seqdata.oauth2.wechat.WechatUserProfile; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @author jrxian + * @date 2020-01-27 01:46 + */ +@Slf4j +@Service +@AllArgsConstructor +public class UserService { + private final UserAccountRepo accountRepo; + private final UserRepo userRepo; + private final UserDetailRepo userDetailRepo; + private final UserAuthorityRepo userAuthorityRepo; + private final RegistrationAuthorityRepo registrationAuthorityRepo; + + /** + * 用户名密码登录:grantType='password' + * 第三方登录:grantType=clientId + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) + public Optional loadUser(String grantType, String username) { + if(OAuth2Constants.PASSWORD.equalsIgnoreCase(grantType)) { + return accountRepo.findByUsername(username); + } else if(OAuth2Constants.MOBILE.equalsIgnoreCase(grantType)) { + return accountRepo.findByMobile(username); + } else if(OAuth2Constants.EMAIL.equalsIgnoreCase(grantType)) { + return accountRepo.findByEmail(username); + } else { + UserDetailId entityId = new UserDetailId(grantType, username); + return userDetailRepo.findById(entityId) + .map(UserDetail::getUser); + } + } + + /** + * 将第三方认证获取的用户信息转换为本系统的 User + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) + public void saveAsUser(String clientId, UserProfile profile) { + UserDetailId entityId = new UserDetailId(clientId, profile.getName()); + Optional optional = userDetailRepo.findById(entityId); + if(!optional.isPresent()) { + //用户不存在 + UserDetail userDetail = new UserDetail(entityId); + + if(profile instanceof WechatUserProfile) { + //查找微信的unionid,如果找到,视为同一个用户 + String unionId = ((WechatUserProfile) profile).getUnionId(); + if(Objects.nonNull(unionId)) { + accountRepo.findByUnionId(unionId) + .ifPresent(account -> userDetail.setUserId(account.getId())); + } + } + + //没有找到微信unionid-全新的用户+分配默认角色 + if(Objects.isNull(userDetail.getUserId())) { + Long userId = saveUser(profile.getNickname(), profile.getAvatar()); + saveAsUserAccount(userId, profile); + saveDefaultAuthorities(userId, clientId); + userDetail.setUserId(userId); + } + + userDetailRepo.save(userDetail); + } + } + + /** + * 如果用户存在就绑定,否则就创建 + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) + public void updateUser(String registrationId, UserProfile profile, @Nullable Principal principal) { + if(Objects.nonNull(principal)) { + //获取当前用户身份 + String localGrantType = SecurityUtils.grantType(principal); + String localUsername = SecurityUtils.username(principal); + loadUser(localGrantType, localUsername).map(UserAccount::getId) + .ifPresent(userId -> bindUser(userId, registrationId, profile)); + } else { + //当用户不存在时自动创建 + saveAsUser(registrationId, profile); + } + } + + /** + * 将第三方认证的用户和本系统的用户绑定 + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) + public void bindUser(long userId, String clientId, UserProfile profile) { + //如果是微信绑定,更新account的union_id + if(profile instanceof WechatUserProfile) { + UserAccount account = accountRepo.getOne(userId); + String unionId = ((WechatUserProfile) profile).getUnionId(); + if(StringUtils.isNotBlank(unionId) && !Objects.equals(unionId, account.getUnionId())) { + // 判断unionId是否已经存在,如果存在则不设置 + if(accountRepo.existsByUnionId(unionId)) { + log.warn("重复设置,unionId:{}, userId:{}", unionId, userId); + unionId = null; + } + } + account.setUnionId(unionId); + accountRepo.save(account); + } + + bindUserDetail(userId, clientId, profile.getName()); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) + public void bindUserDetail(long userId, String clientId, String username) { + UserDetailId entityId = new UserDetailId(clientId, username); + UserDetail entity = userDetailRepo.findById(entityId) + .orElse(new UserDetail(entityId)); + entity.setUserId(userId); + userDetailRepo.save(entity); + } + + /** + * UserDetail修改了user_id,但是原user:临时用户却依然占据着UnionId,未被清理掉; + * 同上fixme, 清理临时用户 + * UserDetailId设置为新#userId; + * UserDetailId旧userId的unionId清理掉并且设置到新的user上 + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) + public void bindUser(long userId, String clientId, String username) { + UserDetailId entityId = new UserDetailId(clientId, username); + UserDetail entity = userDetailRepo.findById(entityId) + .orElse(new UserDetail(entityId)); + //执行清理行为-重置掉unionId + Optional.ofNullable(entity.getUser()) + .ifPresent(clearAccount -> { + String unionId = clearAccount.getUnionId(); + if(StringUtils.isBlank(unionId)) { + return; + } + UserAccount account = accountRepo.getOne(userId); + if(StringUtils.isBlank(account.getUnionId())) { + account.setUnionId(unionId); + accountRepo.save(account); + } + clearAccount.setUnionId(null); + accountRepo.save(clearAccount); + }); + + entity.setUserId(userId); + userDetailRepo.save(entity); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) + public void bindUserPhone(String phone, String clientId, String username, Map attributes) { + UserDetailId entityId = new UserDetailId(clientId, username); + UserDetail entity = userDetailRepo.findById(entityId) + .orElse(new UserDetail(entityId)); + if(Objects.isNull(entity.getUserId())) { + //找不到用户 + UserAccount account = new UserAccount(); + account.setUnionId((String) attributes.get("unionid")); + account.setMobile(phone); + account = accountRepo.save(account); + //绑定第三方-小程序 + entity.setUserId(account.getId()); + userDetailRepo.save(entity); + } else { + //找到用户,绑定手机号到该用户上 + UserAccount account = accountRepo.getOne(entity.getUserId()); + account.setMobile(phone); + accountRepo.save(account); + } + //TODO 设置角色-group-user(后端url权限) + } + + public void saveDefaultAuthorities(long user, String clientId) { + List userAuthorities = registrationAuthorityRepo.findByIdRegistration(clientId) + .stream() + .map(x -> { + RegistrationAuthorityId id = Objects.requireNonNull(x.getId()); + return new UserAuthority(user, id.getAuthority()); + }) + .collect(Collectors.toList()); + if(!CollectionUtils.isEmpty(userAuthorities)) { + userAuthorityRepo.saveAll(userAuthorities); + } + } + + public Long saveUser(String name, String avatar) { + User user = new User(); + user.setName(name); + user.setAvatar(avatar); + User entity = userRepo.save(user); + return entity.getId(); + } + + private void saveAsUserAccount(Long entityId, UserProfile profile) { + UserAccount account = accountRepo.findById(entityId) + .orElse(new UserAccount(entityId)); + account.setUsername(profile.getUsername()); + account.setEmail(profile.getEmail()); + // TODO 唯一性校验确实 + account.setMobile(profile.getMobile()); + //第三方首次创建的账号默认是管理员 + account.setAdmin(Boolean.TRUE); + if(profile instanceof WechatUserProfile) { + //为微信单独保存 union_id + String unionId = ((WechatUserProfile) profile).getUnionId(); + if(StringUtils.isNotBlank(unionId) && !Objects.equals(unionId, account.getUnionId())) { + // 判断unionId是否已经存在,如果存在则不设置 + if(accountRepo.existsByUnionId(unionId)) { + log.warn("重复设置,unionId:{}, userId:{}", unionId, account.getId()); + unionId = null; + } + } + account.setUnionId(unionId); + } + accountRepo.save(account); + } + +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/util/RouterUtils.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/util/RouterUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..9e1123ac6420c606df65e942df382d2cccf23d42 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/util/RouterUtils.java @@ -0,0 +1,59 @@ +package cn.seqdata.oauth2.util; + +import java.util.Objects; + +import cn.seqdata.antd.Router; +import cn.seqdata.oauth2.jpa.perm.JpaPermission; +import cn.seqdata.oauth2.jpa.perm.ModulePermission; +import cn.seqdata.oauth2.jpa.perm.SysPermission; +import cn.seqdata.oauth2.jpa.perm.ViewPermission; + +/** + * @author jrxian + * @date 2020-05-08 20:17 + */ + +public final class RouterUtils { + private RouterUtils() { + } + + public static Router toRouter(SysPermission sysPerm) { + Router router = createRouter(null, sysPerm); + + router.component = "RouteView"; + router.meta.authorities.addAll(sysPerm.getScopes()); + + return router; + } + + public static Router toRouter(Router parent, ModulePermission modulePerm) { + Router router = createRouter(parent, modulePerm); + + router.meta.group = modulePerm.getGroup(); + router.meta.icon = modulePerm.getIcon(); + router.meta.params = modulePerm.getParams(); + router.meta.query = modulePerm.getQuery(); + router.meta.authorities.addAll(modulePerm.getScopes()); + + ViewPermission viewPerm = modulePerm.getViewPerm(); + if(Objects.nonNull(viewPerm)) { + router.component = viewPerm.getComponent(); + router.meta.view = viewPerm.getId(); + router.meta.keepAlive = viewPerm.getKeepAlive(); + } else { + router.redirect = modulePerm.getRedirect(); + } + + return router; + } + + public static Router createRouter(Router parent, JpaPermission permission) { + Router router = new Router(parent); + + router.key = permission.getId(); + router.name = permission.getIdentifier(); + router.meta.title = permission.getName(); + + return router; + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatAuthorizationCodeGrantRequestEntityConverter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatAuthorizationCodeGrantRequestEntityConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..12d693f1bf954a5a597be9a5cd78849fb6d97d8b --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatAuthorizationCodeGrantRequestEntityConverter.java @@ -0,0 +1,42 @@ +package cn.seqdata.oauth2.wechat; + +import java.net.URI; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Author: jrxian + * Date: 2020-01-24 21:26 + */ +public class WechatAuthorizationCodeGrantRequestEntityConverter implements Converter> { + + @Override + public RequestEntity convert(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + ClientRegistration clientRegistration = authorizationCodeGrantRequest.getClientRegistration(); + ClientRegistration.ProviderDetails providerDetails = clientRegistration.getProviderDetails(); + AuthorizationGrantType grantType = authorizationCodeGrantRequest.getGrantType(); + OAuth2AuthorizationExchange authorizationExchange = authorizationCodeGrantRequest.getAuthorizationExchange(); + OAuth2AuthorizationResponse authorizationResponse = authorizationExchange.getAuthorizationResponse(); + + MultiValueMap formParameters = new LinkedMultiValueMap<>(); + formParameters.add("appid", clientRegistration.getClientId()); + formParameters.add("secret", clientRegistration.getClientSecret()); + formParameters.add(OAuth2ParameterNames.GRANT_TYPE, grantType.getValue()); + formParameters.add(OAuth2ParameterNames.CODE, authorizationResponse.getCode()); + + URI uri = URI.create(providerDetails.getTokenUri()); + + return new RequestEntity<>(formParameters, new HttpHeaders(), HttpMethod.POST, uri); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatOAuth2Template.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatOAuth2Template.java new file mode 100644 index 0000000000000000000000000000000000000000..3ef7b7a99ebfb6277c8424d7e66a4d658ea965fe --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatOAuth2Template.java @@ -0,0 +1,58 @@ +package cn.seqdata.oauth2.wechat; + +import java.net.URI; +import java.util.Map; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.util.UriComponentsBuilder; +import com.google.common.collect.Maps; + +import cn.seqdata.oauth2.provider.OAuth2Template; + +/** + * Author: jrxian + * Date: 2020-01-24 21:56 + */ +public class WechatOAuth2Template extends OAuth2Template { + + public WechatOAuth2Template(ClientRegistration registration) { + super(registration, new WechatRestTemplateCustomizer()); + + setAuthorizationCodeEntityConverter(new WechatAuthorizationCodeGrantRequestEntityConverter()); + } + + @Override + public URI buildAuthorizeURI(String state, String redirectURI) { + ClientRegistration.ProviderDetails providerDetails = registration.getProviderDetails(); + + return UriComponentsBuilder.fromUriString(providerDetails.getAuthorizationUri()) + .queryParam(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2ParameterNames.CODE) + .queryParam("appid", registration.getClientId()) + .queryParam(OAuth2ParameterNames.SCOPE, registration.getScopes()) + .queryParam(OAuth2ParameterNames.STATE, state) + .queryParam(OAuth2ParameterNames.REDIRECT_URI, redirectURI) + .build() + .toUri(); + } + + @Override + public OAuth2User loadUser(OAuth2AccessTokenResponse accessTokenResponse) { + DefaultOAuth2UserService client = new DefaultOAuth2UserService(); + client.setRestOperations(restTemplate); + + client.setRequestEntityConverter(new WechatUserRequestConverter()); + + //将openid作为附件请求参数 + OAuth2AccessToken accessToken = accessTokenResponse.getAccessToken(); + Map additionalParameters = Maps.filterKeys(accessTokenResponse.getAdditionalParameters(), "openid"::equals); + + OAuth2UserRequest userRequest = new OAuth2UserRequest(registration, accessToken, additionalParameters); + return client.loadUser(userRequest); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatRestTemplateCustomizer.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatRestTemplateCustomizer.java new file mode 100644 index 0000000000000000000000000000000000000000..8ba7504649318b07492e03adbc2d9e1f8a8bd97a --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatRestTemplateCustomizer.java @@ -0,0 +1,37 @@ +package cn.seqdata.oauth2.wechat; + +import java.util.Collections; +import java.util.List; + +import org.springframework.boot.web.client.RestTemplateCustomizer; +import org.springframework.http.MediaType; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +/** + * Author: jrxian + * Date: 2020-01-24 18:56 + */ +public class WechatRestTemplateCustomizer implements RestTemplateCustomizer { + //微信返回格式是text/plain,因此需要替换兼容的mediaType + private static final List supportedMediaTypes = Collections.singletonList(MediaType.TEXT_PLAIN); + + @Override + public void customize(RestTemplate restTemplate) { + List> messageConverters = restTemplate.getMessageConverters(); + messageConverters.add(new FormHttpMessageConverter()); + + OAuth2AccessTokenResponseHttpMessageConverter accessTokenConverter = new OAuth2AccessTokenResponseHttpMessageConverter(); + accessTokenConverter.setSupportedMediaTypes(supportedMediaTypes); + //微信返回的accessToken和标准不兼容,需要自定义转换函数,在additional中包含openid和unionid + accessTokenConverter.setTokenResponseConverter(new WechatTokenResponseConverter()); + messageConverters.add(accessTokenConverter); + + MappingJackson2HttpMessageConverter jackson2HttpMessageConverter = new MappingJackson2HttpMessageConverter(); + jackson2HttpMessageConverter.setSupportedMediaTypes(supportedMediaTypes); + messageConverters.add(jackson2HttpMessageConverter); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatTokenResponseConverter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatTokenResponseConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..a349c4d1e6058483f7caa2c49b8b71ad8d0d4fa9 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatTokenResponseConverter.java @@ -0,0 +1,30 @@ +package cn.seqdata.oauth2.wechat; + +import java.util.Map; + +import org.apache.commons.lang3.math.NumberUtils; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.StringUtils; +import com.google.common.collect.Maps; + +/** + * Author: jrxian + * Date: 2020-01-25 08:26 + */ +public class WechatTokenResponseConverter implements Converter, OAuth2AccessTokenResponse> { + + @Override + public OAuth2AccessTokenResponse convert(Map params) { + return OAuth2AccessTokenResponse + .withToken(params.remove(OAuth2ParameterNames.ACCESS_TOKEN)) + .tokenType(OAuth2AccessToken.TokenType.BEARER) + .expiresIn(NumberUtils.toLong(params.remove(OAuth2ParameterNames.EXPIRES_IN))) + .refreshToken(params.remove(OAuth2ParameterNames.REFRESH_TOKEN)) + .scopes(StringUtils.commaDelimitedListToSet(params.remove(OAuth2ParameterNames.SCOPE))) + .additionalParameters(Maps.transformValues(params, value -> (Object) value)) + .build(); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatUserProfile.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatUserProfile.java new file mode 100644 index 0000000000000000000000000000000000000000..edfc203378289bee976ee34688ecbe07d215dc40 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatUserProfile.java @@ -0,0 +1,37 @@ +package cn.seqdata.oauth2.wechat; + +import org.springframework.security.oauth2.core.user.OAuth2User; + +import cn.seqdata.oauth2.provider.UserProfile; + +/** + * Author: jrxian + * Date: 2020-01-25 08:41 + */ +@lombok.Getter +@lombok.Setter +public class WechatUserProfile extends UserProfile { + + public WechatUserProfile(OAuth2User delegate) { + super(delegate); + } + + @Override + public String getName() { + return getAttribute("openid"); + } + + @Override + public String getMobile() { + return getAttribute("purePhoneNumber"); + } + + public String getUnionId() { + return getAttribute("unionid"); + } + + @Override + public String getAvatar() { + return getAttribute("headimgurl"); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatUserRequestConverter.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatUserRequestConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..e7046d36552d3bb72948a527f5539b1cc568434d --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wechat/WechatUserRequestConverter.java @@ -0,0 +1,35 @@ +package cn.seqdata.oauth2.wechat; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.web.util.UriBuilder; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Author: jrxian + * Date: 2020-01-27 17:52 + */ +public class WechatUserRequestConverter implements Converter> { + + @Override + public RequestEntity convert(OAuth2UserRequest source) { + ClientRegistration registration = source.getClientRegistration(); + ClientRegistration.ProviderDetails providerDetails = registration.getProviderDetails(); + ClientRegistration.ProviderDetails.UserInfoEndpoint endpoint = providerDetails.getUserInfoEndpoint(); + + OAuth2AccessToken accessToken = source.getAccessToken(); + + UriBuilder builder = UriComponentsBuilder.fromUriString(endpoint.getUri()) + .queryParam(OAuth2ParameterNames.ACCESS_TOKEN, accessToken.getTokenValue()); + + source.getAdditionalParameters() + .forEach(builder::queryParam); + + return new RequestEntity<>(HttpMethod.GET, builder.build()); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wxapp/ClientProperties.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wxapp/ClientProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..826f2f7227a53c443cbff3205ce638cab79d356d --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wxapp/ClientProperties.java @@ -0,0 +1,14 @@ +package cn.seqdata.oauth2.wxapp; + +/** + * @author jrxian + * @date 2020-08-31 17:13 + */ +@lombok.Data +@lombok.Builder +@lombok.AllArgsConstructor +public class ClientProperties { + private final String id; + private String clientId; + private String clientSecret; +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wxapp/WxAppProvider.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wxapp/WxAppProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..c4820861d7f448a4322ce6344e87ded631c51528 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wxapp/WxAppProvider.java @@ -0,0 +1,52 @@ +package cn.seqdata.oauth2.wxapp; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import lombok.AllArgsConstructor; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +import cn.seqdata.oauth2.jpa.oauth.Provider; +import cn.seqdata.oauth2.repos.oauth.RegistrationRepo; +import cn.seqdata.wxapp.util.WxAppUtils; + +/** + * @author jrxian + * @date 2020-08-30 23:26 + */ +@Component +@AllArgsConstructor +public class WxAppProvider implements Function, InitializingBean { + private final Map providers = new HashMap<>(); + + private final RegistrationRepo registrationRepo; + + @Override + public ClientProperties apply(String id) { + return providers.get(id); + } + + @Override + public void afterPropertiesSet() { + registrationRepo.findAll() + .stream() + .filter(x -> Objects.nonNull(x.getClientId()) && Objects.nonNull(x.getClientSecret())) + .filter(x -> Objects.nonNull(x.getProvider())) + .filter(x -> { + Provider provider = x.getProvider(); + return StringUtils.equalsIgnoreCase(WxAppUtils.WXAPP, provider.getId()); + }) + .forEach(x -> { + ClientProperties properties = ClientProperties.builder() + .id(x.getId()) + .clientId(x.getClientId()) + .clientSecret(x.getClientSecret()) + .build(); + providers.put(properties.getId(), properties); + }); + } +} diff --git a/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wxapp/WxAppService.java b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wxapp/WxAppService.java new file mode 100644 index 0000000000000000000000000000000000000000..d7dc94c83c17f6c2abb2c4422eeafdf6cf890a36 --- /dev/null +++ b/seqdata-cloud-authz/src/main/java/cn/seqdata/oauth2/wxapp/WxAppService.java @@ -0,0 +1,123 @@ +package cn.seqdata.oauth2.wxapp; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + +import cn.seqdata.wxapp.WxAppException; +import cn.seqdata.wxapp.WxAppFeignClient; +import cn.seqdata.wxapp.message.*; +import lombok.AllArgsConstructor; + +/** + * @author jrxian + * @date 2020-08-31 00:33 + */ +@Service +@AllArgsConstructor +public class WxAppService { + private final WxAppProvider provider; + private final WxAppFeignClient feignClient; + //将微信access_token缓存到本地,每小时刷新一次 + private final LoadingCache tokenCache = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(new CacheLoader() { + @Override + public String load(@NonNull String id) { + AccessTokenRequest request = new AccessTokenRequest(); + ClientProperties clientProps = provider.apply(id); + request.setAppid(clientProps.getClientId()); + request.setSecret(clientProps.getClientSecret()); + + AccessTokenResponse response = feignClient.accessToken(request); + WxAppException.check(response); + return response.getAccess_token(); + } + }); + private final LoadingCache, String> schemeCache = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(new CacheLoader, String>() { + @Override + public String load(@NonNull Map request) { + String appid = request.keySet() + .stream() + .findFirst() + .orElseThrow(() -> new RuntimeException("appid不存在")); + String token = tokenCache.getUnchecked(appid); + SchemeResponse response = feignClient.generateScheme(token, request.get(appid)); + WxAppException.check(response); + return response.getOpenlink(); + } + }); + private final LoadingCache ticketCache = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(new CacheLoader() { + @Override + public String load(@NonNull String appid) { + String token = tokenCache.getUnchecked(appid); + TicketRequest request = new TicketRequest(); + request.setAccess_token(token); + TicketResponse response = feignClient.getTicket(request); + WxAppException.check(response); + return response.getTicket(); + } + }); + + private final LoadingCache, String> urlLinkCache = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(new CacheLoader, String>() { + @Override + public String load(@NonNull Map request) { + String appid = request.keySet() + .stream() + .findFirst() + .orElseThrow(() -> new RuntimeException("appid不存在")); + String token = tokenCache.getUnchecked(appid); + UrlLinkResponse response = feignClient.generateUrlLink(token, request.get(appid)); + WxAppException.check(response); + return response.getUrl_link(); + } + }); + + public String accessToken(String appid) { + return tokenCache.getUnchecked(appid); + } + + public void invalidToken(String appid, String oldToken) { + String newToken = tokenCache.getUnchecked(appid); + if(!Objects.equals(oldToken, newToken)) { + return; + } + tokenCache.invalidate(appid); + } + + public String scheme(String appid, SchemeRequest request) { + return schemeCache.getUnchecked(Collections.singletonMap(appid, request)); + } + + public String ticket(String appid) { + return ticketCache.getUnchecked(appid); + } + + public String urlLink(String appid, UrlLinkRequest request) { + return urlLinkCache.getUnchecked(Collections.singletonMap(appid, request)); + } + + public Code2SessionResponse signin(String id, String code) { + Code2SessionRequest request = new Code2SessionRequest(); + ClientProperties clientProps = provider.apply(id); + request.appid = clientProps.getClientId(); + request.secret = clientProps.getClientSecret(); + request.js_code = code; + + Code2SessionResponse response = feignClient.code2session(request); + return WxAppException.check(response); + } +} diff --git a/seqdata-cloud-authz/src/main/resources/application.yml b/seqdata-cloud-authz/src/main/resources/application.yml new file mode 100644 index 0000000000000000000000000000000000000000..fff4e435ab387c3095737df1f7a82969569f8f98 --- /dev/null +++ b/seqdata-cloud-authz/src/main/resources/application.yml @@ -0,0 +1,95 @@ +logging: + level: + root: info + cn.seqdata: debug + com.netflix: warn + com.alibaba.nacos: warn + org.springframework: warn + io.swagger: error + springfox.documentation: error +spring: + messages: + basename: i18n/messages + fallback-to-system-locale: true + application: + name: authz + profiles: + active: logging, jackson, jpa + datasource: + driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver + url: jdbc:sqlserver://dev.voltmao.com:1433;DatabaseName=data_space_base + username: voltcat + password: vot@2018 + jpa: + open-in-view: true + database: SQL_SERVER + properties: + hibernate: + enable_lazy_load_no_trans: true + dialect: org.hibernate.dialect.SQLServer2008Dialect + cache: + use_second_level_cache: false + use_query_cache: false + region: + factory_class: org.hibernate.cache.jcache.JCacheRegionFactory + javax: + cache: + missing_cache_strategy: create + cache: + type: simple + mvc: + hiddenmethod: + filter: + enabled: true + data: + jdbc: + repositories: + enabled: false + redis: + repositories: + enabled: false + redis: + host: dev.voltmao.com + password: ftm123 + database: 15 + rabbitmq: + host: dev.voltmao.com + username: seqdata + password: seq@2015 + template: + exchange: syslog + routing-key: syslog.biz.${spring.application.name} + security: + oauth2: + client: + registration: + github: + provider: github + client-id: 4c086b8877a0fd770388 + client-secret: 5a5ecca781d83675bacee68a1e3b94262576bdf7 + redirect-uri: http://localhost:8080/authz/oauth/code/github + wechat: + provider: wechat + client-id: wxa71a478c9d3b8826 + client-secret: 6749859cfa2c4d762cf5f47398927f31 + client-name: 微信 + client-authentication-method: post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/{action}/oauth/code/{registrationId}" + scope: snsapi_login + weixin: + provider: wechat + client-id: wx4c5c26c776f67a4a + client-secret: 3823127d7e67cbbb5ed460b71607db25 + client-name: 微信公众号 + client-authentication-method: post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/{action}/oauth/code/{registrationId}" + scope: snsapi_userinfo + provider: + wechat: + authorization-uri: https://open.weixin.qq.com/connect/qrconnect + token-uri: https://api.weixin.qq.com/sns/oauth2/access_token + user-info-uri: https://api.weixin.qq.com/sns/userinfo + user-info-authentication-method: query + user-name-attribute: openid \ No newline at end of file diff --git a/seqdata-cloud-authz/src/main/resources/bootstrap.yml b/seqdata-cloud-authz/src/main/resources/bootstrap.yml new file mode 100644 index 0000000000000000000000000000000000000000..a7f432d3ca09ee6627d5bad47b4d31e1967e4953 --- /dev/null +++ b/seqdata-cloud-authz/src/main/resources/bootstrap.yml @@ -0,0 +1,17 @@ +spring: + cloud: + nacos: + config: + server-addr: ${NACOS_SERVER_IP:nacos.seqdata.cn}:${NACOS_SERVER_PORT:8848} + cluster-name: ${NACOS_CLUSTER_NAME:DEFAULT} + namespace: ${NACOS_NAMESPACE:public} + group: ${NACOS_GROUP:DEFAULT_GROUP} + file-extension: ${NACOS_FILE_EXT:yml} + shared-configs: + - group: ${spring.cloud.nacos.config.group} + data_id: common.${spring.cloud.nacos.config.file-extension} + discovery: + server-addr: ${spring.cloud.nacos.config.server-addr} + cluster-name: ${spring.cloud.nacos.config.cluster-name} + namespace: ${spring.cloud.nacos.config.namespace} + group: ${spring.cloud.nacos.config.group} \ No newline at end of file diff --git a/seqdata-cloud-authz/src/main/resources/i18n/messages.properties b/seqdata-cloud-authz/src/main/resources/i18n/messages.properties new file mode 100644 index 0000000000000000000000000000000000000000..665a742002ea73c88f9a893cff8c171e6f58c751 --- /dev/null +++ b/seqdata-cloud-authz/src/main/resources/i18n/messages.properties @@ -0,0 +1,47 @@ +AbstractAccessDecisionManager.accessDenied=Access is denied +AbstractLdapAuthenticationProvider.emptyPassword=Empty Password +AbstractSecurityInterceptor.authenticationNotFound=An Authentication object was not found in the SecurityContext +AbstractUserDetailsAuthenticationProvider.badCredentials=Bad credentials +AbstractUserDetailsAuthenticationProvider.credentialsExpired=User credentials have expired +AbstractUserDetailsAuthenticationProvider.disabled=User is disabled +AbstractUserDetailsAuthenticationProvider.expired=User account has expired +AbstractUserDetailsAuthenticationProvider.locked=User account is locked +AbstractUserDetailsAuthenticationProvider.onlySupports=Only UsernamePasswordAuthenticationToken is supported +AccountStatusUserDetailsChecker.credentialsExpired=User credentials have expired +AccountStatusUserDetailsChecker.disabled=User is disabled +AccountStatusUserDetailsChecker.expired=User account has expired +AccountStatusUserDetailsChecker.locked=User account is locked +AclEntryAfterInvocationProvider.noPermission=Authentication {0} has NO permissions to the domain object {1} +AnonymousAuthenticationProvider.incorrectKey=The presented AnonymousAuthenticationToken does not contain the expected key +BindAuthenticator.badCredentials=Bad credentials +BindAuthenticator.emptyPassword=Empty Password +CasAuthenticationProvider.incorrectKey=The presented CasAuthenticationToken does not contain the expected key +CasAuthenticationProvider.noServiceTicket=Failed to provide a CAS service ticket to validate +ConcurrentSessionControlAuthenticationStrategy.exceededAllowed=Maximum sessions of {0} for this principal exceeded +DigestAuthenticationFilter.incorrectRealm=Response realm name {0} does not match system realm name of {1} +DigestAuthenticationFilter.incorrectResponse=Incorrect response +DigestAuthenticationFilter.missingAuth=Missing mandatory digest value for 'auth' QOP; received header {0} +DigestAuthenticationFilter.missingMandatory=Missing mandatory digest value; received header {0} +DigestAuthenticationFilter.nonceCompromised=Nonce token compromised {0} +DigestAuthenticationFilter.nonceEncoding=Nonce is not encoded in Base64; received nonce {0} +DigestAuthenticationFilter.nonceExpired=Nonce has expired/timed out +DigestAuthenticationFilter.nonceNotNumeric=Nonce token should have yielded a numeric first token, but was {0} +DigestAuthenticationFilter.nonceNotTwoTokens=Nonce should have yielded two tokens but was {0} +DigestAuthenticationFilter.usernameNotFound=Username {0} not found +JdbcDaoImpl.noAuthority=User {0} has no GrantedAuthority +JdbcDaoImpl.notFound=User {0} not found +LdapAuthenticationProvider.badCredentials=Bad credentials +LdapAuthenticationProvider.credentialsExpired=User credentials have expired +LdapAuthenticationProvider.disabled=User is disabled +LdapAuthenticationProvider.expired=User account has expired +LdapAuthenticationProvider.locked=User account is locked +LdapAuthenticationProvider.emptyUsername=Empty username not allowed +LdapAuthenticationProvider.onlySupports=Only UsernamePasswordAuthenticationToken is supported +PasswordComparisonAuthenticator.badCredentials=Bad credentials +PersistentTokenBasedRememberMeServices.cookieStolen=Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack. +ProviderManager.providerNotFound=No AuthenticationProvider found for {0} +RememberMeAuthenticationProvider.incorrectKey=The presented RememberMeAuthenticationToken does not contain the expected key +RunAsImplAuthenticationProvider.incorrectKey=The presented RunAsUserToken does not contain the expected key +SubjectDnX509PrincipalExtractor.noMatching=No matching pattern was found in subjectDN: {0} +SwitchUserFilter.noCurrentUser=No current user associated with this request +SwitchUserFilter.noOriginalAuthentication=Could not find original Authentication object diff --git a/seqdata-cloud-authz/src/main/resources/i18n/messages_zh_CN.properties b/seqdata-cloud-authz/src/main/resources/i18n/messages_zh_CN.properties new file mode 100644 index 0000000000000000000000000000000000000000..0fcdf8f99ed46fd02deb72e07246357de036b1cf --- /dev/null +++ b/seqdata-cloud-authz/src/main/resources/i18n/messages_zh_CN.properties @@ -0,0 +1,47 @@ +AbstractAccessDecisionManager.accessDenied=\u4E0D\u5141\u8BB8\u8BBF\u95EE +AbstractLdapAuthenticationProvider.emptyPassword=\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef +AbstractSecurityInterceptor.authenticationNotFound=\u672A\u5728SecurityContext\u4E2D\u67E5\u627E\u5230\u8BA4\u8BC1\u5BF9\u8C61 +AbstractUserDetailsAuthenticationProvider.badCredentials=\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef +AbstractUserDetailsAuthenticationProvider.credentialsExpired=\u7528\u6237\u51ED\u8BC1\u5DF2\u8FC7\u671F +AbstractUserDetailsAuthenticationProvider.disabled=\u7528\u6237\u5DF2\u5931\u6548 +AbstractUserDetailsAuthenticationProvider.expired=\u7528\u6237\u5E10\u53F7\u5DF2\u8FC7\u671F +AbstractUserDetailsAuthenticationProvider.locked=\u7528\u6237\u5E10\u53F7\u5DF2\u88AB\u9501\u5B9A +AbstractUserDetailsAuthenticationProvider.onlySupports=\u4EC5\u4EC5\u652F\u6301UsernamePasswordAuthenticationToken +AccountStatusUserDetailsChecker.credentialsExpired=\u7528\u6237\u51ED\u8BC1\u5DF2\u8FC7\u671F +AccountStatusUserDetailsChecker.disabled=\u7528\u6237\u5DF2\u5931\u6548 +AccountStatusUserDetailsChecker.expired=\u7528\u6237\u5E10\u53F7\u5DF2\u8FC7\u671F +AccountStatusUserDetailsChecker.locked=\u7528\u6237\u5E10\u53F7\u5DF2\u88AB\u9501\u5B9A +AclEntryAfterInvocationProvider.noPermission=\u7ED9\u5B9A\u7684Authentication\u5BF9\u8C61({0})\u6839\u672C\u65E0\u6743\u64CD\u63A7\u9886\u57DF\u5BF9\u8C61({1}) +AnonymousAuthenticationProvider.incorrectKey=\u5C55\u793A\u7684AnonymousAuthenticationToken\u4E0D\u542B\u6709\u9884\u671F\u7684key +BindAuthenticator.badCredentials=\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef +BindAuthenticator.emptyPassword=\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef +CasAuthenticationProvider.incorrectKey=\u5C55\u793A\u7684CasAuthenticationToken\u4E0D\u542B\u6709\u9884\u671F\u7684key +CasAuthenticationProvider.noServiceTicket=\u672A\u80FD\u591F\u6B63\u786E\u63D0\u4F9B\u5F85\u9A8C\u8BC1\u7684CAS\u670D\u52A1\u7968\u6839 +ConcurrentSessionControlAuthenticationStrategy.exceededAllowed=\u5DF2\u7ECF\u8D85\u8FC7\u4E86\u5F53\u524D\u4E3B\u4F53({0})\u88AB\u5141\u8BB8\u7684\u6700\u5927\u4F1A\u8BDD\u6570\u91CF +DigestAuthenticationFilter.incorrectRealm=\u54CD\u5E94\u7ED3\u679C\u4E2D\u7684Realm\u540D\u5B57({0})\u540C\u7CFB\u7EDF\u6307\u5B9A\u7684Realm\u540D\u5B57({1})\u4E0D\u543B\u5408 +DigestAuthenticationFilter.incorrectResponse=\u9519\u8BEF\u7684\u54CD\u5E94\u7ED3\u679C +DigestAuthenticationFilter.missingAuth=\u9057\u6F0F\u4E86\u9488\u5BF9'auth' QOP\u7684\u3001\u5FC5\u987B\u7ED9\u5B9A\u7684\u6458\u8981\u53D6\u503C; \u63A5\u6536\u5230\u7684\u5934\u4FE1\u606F\u4E3A{0} +DigestAuthenticationFilter.missingMandatory=\u9057\u6F0F\u4E86\u5FC5\u987B\u7ED9\u5B9A\u7684\u6458\u8981\u53D6\u503C; \u63A5\u6536\u5230\u7684\u5934\u4FE1\u606F\u4E3A{0} +DigestAuthenticationFilter.nonceCompromised=Nonce\u4EE4\u724C\u5DF2\u7ECF\u5B58\u5728\u95EE\u9898\u4E86\uFF0C{0} +DigestAuthenticationFilter.nonceEncoding=Nonce\u672A\u7ECF\u8FC7Base64\u7F16\u7801; \u76F8\u5E94\u7684nonce\u53D6\u503C\u4E3A {0} +DigestAuthenticationFilter.nonceExpired=Nonce\u5DF2\u7ECF\u8FC7\u671F/\u8D85\u65F6 +DigestAuthenticationFilter.nonceNotNumeric=Nonce\u4EE4\u724C\u7684\u7B2C1\u90E8\u5206\u5E94\u8BE5\u662F\u6570\u5B57\uFF0C\u4F46\u7ED3\u679C\u5374\u662F{0} +DigestAuthenticationFilter.nonceNotTwoTokens=Nonce\u5E94\u8BE5\u7531\u4E24\u90E8\u5206\u53D6\u503C\u6784\u6210\uFF0C\u4F46\u7ED3\u679C\u5374\u662F{0} +DigestAuthenticationFilter.usernameNotFound=\u7528\u6237\u540D{0}\u672A\u627E\u5230 +JdbcDaoImpl.noAuthority=\u6CA1\u6709\u4E3A\u7528\u6237{0}\u6307\u5B9A\u89D2\u8272 +JdbcDaoImpl.notFound=\u672A\u627E\u5230\u7528\u6237{0} +LdapAuthenticationProvider.badCredentials=\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef +LdapAuthenticationProvider.credentialsExpired=\u7528\u6237\u51ED\u8BC1\u5DF2\u8FC7\u671F +LdapAuthenticationProvider.disabled=\u7528\u6237\u5DF2\u5931\u6548 +LdapAuthenticationProvider.expired=\u7528\u6237\u5E10\u53F7\u5DF2\u8FC7\u671F +LdapAuthenticationProvider.locked=\u7528\u6237\u5E10\u53F7\u5DF2\u88AB\u9501\u5B9A +LdapAuthenticationProvider.emptyUsername=\u7528\u6237\u540D\u4E0D\u5141\u8BB8\u4E3A\u7A7A +LdapAuthenticationProvider.onlySupports=\u4EC5\u4EC5\u652F\u6301UsernamePasswordAuthenticationToken +PasswordComparisonAuthenticator.badCredentials=\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef +#PersistentTokenBasedRememberMeServices.cookieStolen=Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack. +ProviderManager.providerNotFound=\u672A\u67E5\u627E\u5230\u9488\u5BF9{0}\u7684AuthenticationProvider +RememberMeAuthenticationProvider.incorrectKey=\u5C55\u793ARememberMeAuthenticationToken\u4E0D\u542B\u6709\u9884\u671F\u7684key +RunAsImplAuthenticationProvider.incorrectKey=\u5C55\u793A\u7684RunAsUserToken\u4E0D\u542B\u6709\u9884\u671F\u7684key +SubjectDnX509PrincipalExtractor.noMatching=\u672A\u5728subjectDN\: {0}\u4E2D\u627E\u5230\u5339\u914D\u7684\u6A21\u5F0F +SwitchUserFilter.noCurrentUser=\u4E0D\u5B58\u5728\u5F53\u524D\u7528\u6237 +SwitchUserFilter.noOriginalAuthentication=\u4E0D\u80FD\u591F\u67E5\u627E\u5230\u539F\u5148\u7684\u5DF2\u8BA4\u8BC1\u5BF9\u8C61 diff --git a/seqdata-cloud-authz/src/main/resources/redisson-jcache.yaml b/seqdata-cloud-authz/src/main/resources/redisson-jcache.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4769dc58ea01340756513537631554831fd12a4e --- /dev/null +++ b/seqdata-cloud-authz/src/main/resources/redisson-jcache.yaml @@ -0,0 +1,3 @@ +singleServerConfig: + address: redis://${spring.redis.host}:${spring.redis.port} + password: ${spring.redis.password} \ No newline at end of file diff --git a/seqdata-cloud-authz/src/test/java/PasswordTest.java b/seqdata-cloud-authz/src/test/java/PasswordTest.java new file mode 100644 index 0000000000000000000000000000000000000000..aef74ce7f25b4279009baa53962e2f9c44b35af0 --- /dev/null +++ b/seqdata-cloud-authz/src/test/java/PasswordTest.java @@ -0,0 +1,14 @@ +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * @author jrxian + * @date 2021/1/13 17:00 + */ +public class PasswordTest { + public static void main(String[] args) { + PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + String encodedPassword = "{bcrypt}$2a$10$b.EowYVwXK3a6ps2dYxV/esrAQ7vNaLhpz1BaTVj48FkVHNp0Yv/a"; + System.err.println(encoder.matches("12345678", encodedPassword)); + } +} diff --git a/seqdata-cloud-gateway/pom.xml b/seqdata-cloud-gateway/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..3b749b2bf50516dfd394275b0dc8f7276e790b60 --- /dev/null +++ b/seqdata-cloud-gateway/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + cn.seqdata.cloud + seqdata-cloud-parent + 2.2.1-SNAPSHOT + + seqdata-cloud-gateway + 网关,提供路由功能和其它一些通过用功能(防重放攻击、url过滤、统一日志等等) + + + com.github.whvcse + easy-captcha + 1.6.2 + + + + org.springframework.boot + spring-boot-starter-amqp + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.cloud + spring-cloud-starter-gateway + + + org.springframework.cloud + spring-cloud-starter-netflix-hystrix + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.cloud + spring-cloud-starter-netflix-ribbon + + + + cn.seqdata.cloud + seqdata-cloud-auth-client + ${project.version} + + + + + + com.spotify + docker-maven-plugin + 1.2.2 + + + sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories + apk add --update ttf-dejavu fontconfig + rm -rf /var/cache/apk/* + ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime + echo 'Asia/Shanghai' > /etc/timezone + + + + + + diff --git a/seqdata-cloud-gateway/src/main/docker/Dockerfile b/seqdata-cloud-gateway/src/main/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..c3f5bb968e99ef901314ce80614e4440e80d8b8e --- /dev/null +++ b/seqdata-cloud-gateway/src/main/docker/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:8-jdk-alpine +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories +RUN apk add --update ttf-dejavu fontconfig && rm -rf /var/cache/apk/* +VOLUME /tmp +ADD seqdata-cloud-gateway-2.2.1-SNAPSHOT.jar myapp.jar +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo '$TZ' > /etc/timezone +ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","-Dspring.application.name=gateway","/myapp.jar"] \ No newline at end of file diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/GatewayApplication.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/GatewayApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..830ee837932af9af8226e213256375cbedd67cc9 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/GatewayApplication.java @@ -0,0 +1,19 @@ +package cn.seqdata.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +/** + * Author: jrxian + * Date: 2019-10-02 19:11 + */ +@SpringBootApplication(exclude = ReactiveSecurityAutoConfiguration.class) +@EnableDiscoveryClient +public class GatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/GatewayConfiguration.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/GatewayConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..6f2aaa79542bb91723d99d766992898fd06b340b --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/GatewayConfiguration.java @@ -0,0 +1,85 @@ +package cn.seqdata.gateway; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices; + +import cn.seqdata.gateway.filter.authc.AllowlistPredicate; +import cn.seqdata.gateway.filter.authc.IgnoringProperties; +import cn.seqdata.gateway.filter.authc.PathMatcherGlobalFilter; +import cn.seqdata.gateway.filter.captcha.CaptchaGlobalFilter; +import cn.seqdata.gateway.filter.captcha.CaptchaProperties; +import cn.seqdata.gateway.filter.logging.LogRecorder; +import cn.seqdata.gateway.filter.logging.LoggingGlobalFilter; +import cn.seqdata.gateway.filter.logging.RabbitRecorder; +import cn.seqdata.gateway.filter.logging.UrlRecord; +import cn.seqdata.gateway.filter.verify.HmacVerifyGlobalFilter; +import cn.seqdata.oauth2.client.security.PathPermissionEvaluator; + +/** + * Author: jrxian + * Date: 2020-02-07 21:09 + */ +@Slf4j +@Configuration +@EnableConfigurationProperties({IgnoringProperties.class, CaptchaProperties.class}) +public class GatewayConfiguration { + + @Bean + public MessageConverter messageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + @ConditionalOnProperty("spring.rabbitmq.host") + public RabbitRecorder rabbitLogRecorder(RabbitTemplate rabbitTemplate) { + return new RabbitRecorder<>(rabbitTemplate); + } + + @Bean + @ConditionalOnMissingBean(RabbitRecorder.class) + public LogRecorder logRecorder() { + return record -> log.debug(String.valueOf(record)); + } + + @Bean + @ConditionalOnProperty(value = "spring.cloud.gateway.logging.enabled", matchIfMissing = true) + public LoggingGlobalFilter loggingGlobalFilter(LogRecorder logRecorder, ResourceServerTokenServices tokenServices) { + return new LoggingGlobalFilter(logRecorder, tokenServices); + } + + @Bean + public CaptchaGlobalFilter captchaGlobalFilter(CaptchaProperties properties, StringRedisTemplate redisTemplate) { + return new CaptchaGlobalFilter(properties, redisTemplate); + } + + @Bean + public AllowlistPredicate allowlistPredicate(IgnoringProperties properties) { + return new AllowlistPredicate(properties); + } + + @Bean + @ConditionalOnProperty(value = "spring.cloud.gateway.path-matcher.enabled", matchIfMissing = true) + public PathMatcherGlobalFilter pathMatcherGlobalFilter(AllowlistPredicate allowlistPredicate, + ResourceServerTokenServices tokenServices, PathPermissionEvaluator permissionEvaluator) { + return new PathMatcherGlobalFilter(allowlistPredicate, tokenServices, permissionEvaluator); + } + + /** + * 对前端签名进行验证 + */ + @Bean + @ConditionalOnProperty(value = "spring.cloud.gateway.hmac-verify.enabled") + public HmacVerifyGlobalFilter hmacVerifyGlobalFilter() { + return new HmacVerifyGlobalFilter(); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/JacksonConfiguration.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/JacksonConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..65d8c0a60b8d9b92f3d58ae248293efd7282c47c --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/JacksonConfiguration.java @@ -0,0 +1,57 @@ +package cn.seqdata.gateway; + +import java.io.IOException; +import java.util.Collection; + +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.util.NumberUtils; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +/** + * Author: jrxian + * Date: 2020-02-11 01:20 + */ +@Configuration +public class JacksonConfiguration { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer objectMapperBuilderCustomizer() { + return builder -> { + builder.deserializerByType(long.class, JacksonConfiguration.LongDeserializer.instance); + builder.deserializerByType(Long.class, JacksonConfiguration.LongDeserializer.instance); + }; + } + + @Bean + public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(ObjectMapper objectMapper) { + return new MappingJackson2HttpMessageConverter(objectMapper); + } + + @Bean + public HttpMessageConverters httpMessageConverters(Collection> additionalConverters) { + return new HttpMessageConverters(additionalConverters); + } + + static final class LongDeserializer extends StdDeserializer { + private static final long serialVersionUID = 1L; + + public static final JacksonConfiguration.LongDeserializer instance = new JacksonConfiguration.LongDeserializer(); + + private LongDeserializer() { + super(Long.class); + } + + @Override + public Long deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { + return NumberUtils.parseNumber(jp.getText(), Long.class); + } + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AllowlistController.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AllowlistController.java new file mode 100644 index 0000000000000000000000000000000000000000..f2da6437d6ebbe0ffefd6d9a8eeedf517b374764 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AllowlistController.java @@ -0,0 +1,55 @@ +package cn.seqdata.gateway.filter.authc; + +import java.util.Collection; +import lombok.AllArgsConstructor; + +import org.springframework.web.bind.annotation.*; + +/** + * @author jrxian + * @date 2020-11-12 00:24 + */ +@RestController +@RequestMapping("/allow") +@AllArgsConstructor +public class AllowlistController { + private final AllowlistPredicate allowlistPredicate; + private final AnonymousService anonymousService; + + @PostMapping + public void save(@RequestBody String url) { + allowlistPredicate.allowlist.add(url); + } + + @DeleteMapping + public void delete(@RequestBody String url) { + allowlistPredicate.allowlist.remove(url); + } + + @GetMapping("/list") + public Collection findAll() { + return allowlistPredicate.allowlist; + } + + @PostMapping("/list") + public void saveAll(@RequestBody Collection allowlist) { + allowlistPredicate.allowlist.clear(); + allowlistPredicate.allowlist.addAll(allowlist); + } + + @DeleteMapping("/list") + public void deleteAll() { + allowlistPredicate.allowlist.clear(); + } + + @GetMapping("/anonymous") + public Collection getAnonymous() { + return allowlistPredicate.anonymous; + } + + @PostMapping("/anonymous") + public void refreshAnonymous() { + allowlistPredicate.anonymous.clear(); + allowlistPredicate.anonymous.addAll(anonymousService.get()); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AllowlistPredicate.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AllowlistPredicate.java new file mode 100644 index 0000000000000000000000000000000000000000..c3940f63ab3f05089bfc41cc8c746c5fcd37073b --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AllowlistPredicate.java @@ -0,0 +1,67 @@ +package cn.seqdata.gateway.filter.authc; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Predicate; + +import org.apache.commons.lang3.ArrayUtils; +import org.springframework.http.server.RequestPath; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.CollectionUtils; +import org.springframework.util.PathMatcher; + +/** + * Author: jrxian + * Date: 2020-02-12 23:49 + */ +public class AllowlistPredicate implements Predicate { + protected static final PathMatcher pathMatcher = new AntPathMatcher(); + private static final String[] defAllowlist = {"/authz/**" + , "/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/v2/api-docs/**", "/**/anonymous/**"}; + + public final Set allowlist = new HashSet<>(); + public final Set anonymous = new HashSet<>(); + + public AllowlistPredicate() { + //authz: 授权服务器是否需要token由授权服务器决定,网关不做前置拦截 + allowlist.addAll(Arrays.asList(defAllowlist)); + } + + public AllowlistPredicate(String... allowlist) { + this(); + if(ArrayUtils.isNotEmpty(allowlist)) { + this.allowlist.addAll(Arrays.asList(allowlist)); + } + } + + public AllowlistPredicate(Collection allowlist) { + this(); + if(!CollectionUtils.isEmpty(allowlist)) { + this.allowlist.addAll(allowlist); + } + } + + @Override + public boolean test(ServerHttpRequest request) { + RequestPath requestPath = request.getPath(); + String path = requestPath.value(); + + //静态配置的白名单 + for(String pattern : allowlist) { + if(pathMatcher.match(pattern, path)) { + return true; + } + } + //匿名用户动态配置的白名单,需要定期刷新 + for(String pattern : anonymous) { + if(pathMatcher.match(pattern, path)) { + return true; + } + } + + return false; + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AnonymousService.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AnonymousService.java new file mode 100644 index 0000000000000000000000000000000000000000..ef346ee03e953f174ab3f705a37065618c478ba4 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AnonymousService.java @@ -0,0 +1,39 @@ +package cn.seqdata.gateway.filter.authc; + +import java.util.Collections; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; + +import org.springframework.stereotype.Service; + +import cn.seqdata.oauth2.client.DefaultRole; +import cn.seqdata.oauth2.client.FeignAuthzClient; +import cn.seqdata.oauth2.client.entity.AuthorityPermission; +import cn.seqdata.oauth2.client.entity.IdentifiableObject; +import cn.seqdata.oauth2.client.entity.NamedObject; + +/** + * @author jrxian + * @date 2020-11-12 00:26 + * 从Feign中获取anonymous角色的url授权 + */ +@Service +@AllArgsConstructor +public class AnonymousService implements Supplier> { + private final FeignAuthzClient authzClient; + + @Override + public Set get() { + return authzClient.findAuthorityByIdentifier(DefaultRole.anonymous.name()) + .map(NamedObject::getId) + .map(x -> authzClient.findPermissions(x, "url") + .stream() + .map(AuthorityPermission::getPermission) + .map(IdentifiableObject::getIdentifier) + .collect(Collectors.toSet()) + ) + .orElse(Collections.emptySet()); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AuthClientConfiguration.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AuthClientConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..1ccb6159a875e77a3c4827c849800253311be26f --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/AuthClientConfiguration.java @@ -0,0 +1,82 @@ +package cn.seqdata.gateway.filter.authc; + +import java.io.IOException; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.NonNull; +import org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter; +import org.springframework.security.oauth2.provider.token.RemoteTokenServices; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.RestTemplate; + +import cn.seqdata.gateway.swagger.OAuth2ClientProperties; +import cn.seqdata.oauth2.client.DefaultRole; +import cn.seqdata.oauth2.client.FeignAuthzClient; +import cn.seqdata.oauth2.client.security.EnhanceUserAuthenticationConverter; +import cn.seqdata.oauth2.client.security.PathPermissionEvaluatorImpl; + +/** + * Author: jrxian + * Date: 2020-02-14 01:03 + */ +@Configuration +@EnableFeignClients(basePackageClasses = FeignAuthzClient.class) +@EnableConfigurationProperties({OAuth2ClientProperties.class, ResourceServerProperties.class}) +public class AuthClientConfiguration { + + @Bean + @LoadBalanced + public RestTemplate restTemplate() { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setErrorHandler(new DefaultResponseErrorHandler() { + public void handleError(@NonNull ClientHttpResponse response) throws IOException { + if(response.getRawStatusCode() != 400) { + super.handleError(response); + } + } + }); + return restTemplate; + } + + /** + * 连接授权服务器 + */ + @Bean + public RemoteTokenServices remoteTokenServices(RestTemplate restTemplate, + OAuth2ClientProperties credentials, ResourceServerProperties resource) { + EnhanceUserAuthenticationConverter authenticationConverter = new EnhanceUserAuthenticationConverter(); + authenticationConverter.setDefaultAuthorities(new String[]{DefaultRole.anonymous.name()}); + + DefaultAccessTokenConverter tokenConverter = new DefaultAccessTokenConverter(); + tokenConverter.setUserTokenConverter(authenticationConverter); + + RemoteTokenServices services = new RemoteTokenServices(); + services.setRestTemplate(restTemplate); + services.setClientId(credentials.getClientId()); + services.setClientSecret(credentials.getClientSecret()); + services.setCheckTokenEndpointUrl(resource.getTokenInfoUri()); + services.setAccessTokenConverter(tokenConverter); + + return services; + } + + /** + * 对授权服务器返回的accessToken做短期缓存 + */ + @Bean + @Primary + public CachedResourceServerTokenServices tokenServices(RemoteTokenServices tokenServices) { + return new CachedResourceServerTokenServices(tokenServices); + } + + @Bean + public PathPermissionEvaluatorImpl permissionEvaluator(FeignAuthzClient authzClient) { + return new PathPermissionEvaluatorImpl("url", authzClient); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/BearerTokenResolver.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/BearerTokenResolver.java new file mode 100644 index 0000000000000000000000000000000000000000..d7a786c3fac931ab19e808ed4fd7befa4cdfc8ee --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/BearerTokenResolver.java @@ -0,0 +1,11 @@ +package cn.seqdata.gateway.filter.authc; + +import org.springframework.http.server.reactive.ServerHttpRequest; + +/** + * Author: jrxian + * Date: 2020-02-12 23:17 + */ +public interface BearerTokenResolver { + String resolve(ServerHttpRequest request); +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/CacheController.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/CacheController.java new file mode 100644 index 0000000000000000000000000000000000000000..6eaf66fef3d154cf22bbdb0bebf7e22e6723d942 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/CacheController.java @@ -0,0 +1,66 @@ +package cn.seqdata.gateway.filter.authc; + +import java.util.Map; +import lombok.AllArgsConstructor; + +import org.springframework.web.bind.annotation.*; + +import cn.seqdata.oauth2.client.security.PathPermission; +import cn.seqdata.oauth2.client.security.PathPermissionEvaluatorImpl; + +/** + * @author jrxian + * @date 2020-11-11 23:03 + */ +@RestController +@RequestMapping("/cache") +@AllArgsConstructor +public class CacheController { + private final CachedResourceServerTokenServices tokenServices; + private final PathPermissionEvaluatorImpl permissionEvaluator; + + @DeleteMapping(value = "/authentication") + public void invalidateAuthentications() { + tokenServices.authenticationCache.invalidateAll(); + } + + @DeleteMapping(value = "/authentication/{key}") + public void invalidateAuthentication(@PathVariable("key") String key) { + tokenServices.authenticationCache.invalidate(key); + } + + @DeleteMapping(value = "/token") + public void invalidateTokens() { + tokenServices.accessTokenCache.invalidateAll(); + } + + @DeleteMapping(value = "/token/{key}") + public void invalidateToken(@PathVariable("key") String key) { + tokenServices.accessTokenCache.invalidate(key); + } + + @GetMapping(value = "/authorities") + public Map getAuthorities() { + return permissionEvaluator.idCache.asMap(); + } + + @DeleteMapping(value = "/authorities") + public void invalidateAuthoritie() { + permissionEvaluator.idCache.invalidateAll(); + } + + @GetMapping(value = "/authoritiy/{id}/permissions") + public Map getPermissions(@PathVariable("id") long id) { + return permissionEvaluator.permCache.getUnchecked(id); + } + + @DeleteMapping(value = "/authoritiy/{id}/permissions") + public void invalidatePermissions(@PathVariable("id") long id) { + permissionEvaluator.permCache.invalidate(id); + } + + @DeleteMapping(value = "/permissions") + public void invalidatePermissions() { + permissionEvaluator.permCache.invalidateAll(); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/CachedResourceServerTokenServices.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/CachedResourceServerTokenServices.java new file mode 100644 index 0000000000000000000000000000000000000000..7001fb65d5c620056d0c020f2fe6e5a63ab470fd --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/CachedResourceServerTokenServices.java @@ -0,0 +1,61 @@ +package cn.seqdata.gateway.filter.authc; + +import java.util.concurrent.TimeUnit; +import lombok.AllArgsConstructor; + +import org.springframework.lang.NonNull; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.common.exceptions.InvalidTokenException; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.util.concurrent.UncheckedExecutionException; + +/** + * 缓存token,避免高频访问授权服务器 + */ +@AllArgsConstructor +public class CachedResourceServerTokenServices implements ResourceServerTokenServices { + private final ResourceServerTokenServices delegate; + + public final LoadingCache authenticationCache = CacheBuilder.newBuilder() + .maximumSize(1024) + .expireAfterAccess(30, TimeUnit.SECONDS) + .build(new CacheLoader() { + @Override + public OAuth2Authentication load(@NonNull String key) { + return delegate.loadAuthentication(key); + } + }); + + public final LoadingCache accessTokenCache = CacheBuilder.newBuilder() + .maximumSize(1024) + .expireAfterAccess(30, TimeUnit.SECONDS) + .build(new CacheLoader() { + @Override + public OAuth2AccessToken load(@NonNull String key) { + return delegate.readAccessToken(key); + } + }); + + @Override + public OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException { + try { + return authenticationCache.getUnchecked(accessToken); + } catch(UncheckedExecutionException ex) { + throw new InvalidTokenException(accessToken, ex); + } + } + + @Override + public OAuth2AccessToken readAccessToken(String accessToken) { + try { + return accessTokenCache.getUnchecked(accessToken); + } catch(UncheckedExecutionException ex) { + throw new InvalidTokenException(accessToken, ex); + } + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/DefaultBearerTokenResolver.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/DefaultBearerTokenResolver.java new file mode 100644 index 0000000000000000000000000000000000000000..11beeb684e9c25deff13f0ff8e521912331d13b2 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/DefaultBearerTokenResolver.java @@ -0,0 +1,38 @@ +package cn.seqdata.gateway.filter.authc; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +/** + * Author: jrxian + * Date: 2020-02-12 23:19 + */ +public class DefaultBearerTokenResolver implements BearerTokenResolver { + private static final Pattern authorizationPattern = Pattern.compile( + "^Bearer (?[a-zA-Z0-9-._~+/]+)=*$", + Pattern.CASE_INSENSITIVE); + + @Override + public String resolve(ServerHttpRequest request) { + HttpHeaders headers = request.getHeaders(); + String authorization = headers.getFirst(HttpHeaders.AUTHORIZATION); + + if(StringUtils.startsWithIgnoreCase(authorization, "bearer")) { + Matcher matcher = authorizationPattern.matcher(authorization); + + if(!matcher.matches()) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Bearer token is malformed"); + } + + return matcher.group("token"); + } + + return null; + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/IgnoringProperties.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/IgnoringProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..6d41ea9bdfdd1b3f207e3cefc57445481c997bf0 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/IgnoringProperties.java @@ -0,0 +1,15 @@ +package cn.seqdata.gateway.filter.authc; + +import java.util.ArrayList; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author jrxian + * @date 2020-11-26 15:38 + */ +@lombok.Getter +@lombok.Setter +@ConfigurationProperties(prefix = "security.oauth2.resource.ignoring") +public class IgnoringProperties extends ArrayList { +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/PathMatcherGlobalFilter.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/PathMatcherGlobalFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..65eda3ef20331c134ae39f26c48024e9bbde4d48 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/PathMatcherGlobalFilter.java @@ -0,0 +1,73 @@ +package cn.seqdata.gateway.filter.authc; + +import java.util.Objects; +import java.util.function.Predicate; +import lombok.AllArgsConstructor; + +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.RequestPath; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.common.exceptions.InvalidTokenException; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import cn.seqdata.oauth2.client.security.PathPermissionEvaluator; + +/** + * 根据url权限在网关统一拦截 + */ +@AllArgsConstructor +public class PathMatcherGlobalFilter implements GlobalFilter, Ordered { + private final BearerTokenResolver tokenResolver = new DefaultBearerTokenResolver(); + private final Predicate allowPredicate; + private final ResourceServerTokenServices tokenServices; + private final PathPermissionEvaluator permissionEvaluator; + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + HttpMethod httpMethod = request.getMethod(); + RequestPath requestPath = request.getPath(); + + //白名单和OPTIONS,不需要做权限验证 + if(HttpMethod.OPTIONS.equals(httpMethod) || !allowPredicate.test(request)) { + //从HttpHeaders获取accessToken + String accessToken = tokenResolver.resolve(request); + if(Objects.isNull(accessToken)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "To provide a valid accessToken"); + } + + try { + //从授权服务器获取Authentication + OAuth2Authentication authentication = tokenServices.loadAuthentication(accessToken); + + //前端页面访问,不是由appid发起的 + if(!authentication.isClientOnly()) { + Authentication userAuthentication = authentication.getUserAuthentication(); + + //没有权限,报403 + if(!permissionEvaluator.hasPermission(userAuthentication, requestPath.value())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "URL access verification failed"); + } + } + } catch(InvalidTokenException ex) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + } + + return chain.filter(exchange); + } + + @Override + public int getOrder() { + return -60; + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/ResourceServerProperties.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/ResourceServerProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..91d4c1b8a1f47f232d1230f5765d867e76d7a078 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/authc/ResourceServerProperties.java @@ -0,0 +1,16 @@ +package cn.seqdata.gateway.filter.authc; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Author: jrxian + * Date: 2020-02-13 16:41 + */ +@lombok.Getter +@lombok.Setter +@ConfigurationProperties(prefix = "security.oauth2.resource") +public class ResourceServerProperties { + private String userInfoUri; + private String tokenInfoUri; + private boolean preferTokenInfo = true; +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaController.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaController.java new file mode 100644 index 0000000000000000000000000000000000000000..1fdf971d488abc1bcedb3124871360f98f2c93a7 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaController.java @@ -0,0 +1,61 @@ +package cn.seqdata.gateway.filter.captcha; + +import java.io.ByteArrayOutputStream; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang3.RandomUtils; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.bind.annotation.RestController; +import com.wf.captcha.base.Captcha; + +/** + * @author jrxian + * @date 2020-11-25 11:12 + */ +@RestController +@RequestMapping("/captcha") +public class CaptchaController { + private final StringRedisTemplate redisTemplate; + private final ValueOperations opsForValue; + + public CaptchaController(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.opsForValue = redisTemplate.opsForValue(); + } + + @GetMapping + public ResponseEntity captcha(String type, String uuid + , @RequestParam(defaultValue = "130") int width + , @RequestParam(defaultValue = "48") int height + , @RequestParam(defaultValue = "4") int len) { + + CaptchaType captchaType; + try { + captchaType = CaptchaType.valueOf(type); + } catch(RuntimeException ex) { + CaptchaType[] captchaTypes = CaptchaType.values(); + captchaType = captchaTypes[RandomUtils.nextInt(0, captchaTypes.length)]; + } + + Captcha captcha = captchaType.apply(width, height, len); + opsForValue.set(uuid, captcha.text()); + redisTemplate.expire(uuid, 1, TimeUnit.MINUTES); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + captcha.out(os); + + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_GIF) + .cacheControl(CacheControl.noCache()) + .header(HttpHeaders.PRAGMA, "No-cache") + .header(HttpHeaders.EXPIRES, "0") + .body(os.toByteArray()); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaException.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaException.java new file mode 100644 index 0000000000000000000000000000000000000000..e758953c01e45d3e90556b5a2d6cb851222c8604 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaException.java @@ -0,0 +1,19 @@ +package cn.seqdata.gateway.filter.captcha; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** + * @author jrxian + * @date 2020-11-26 14:45 + */ +public class CaptchaException extends ResponseStatusException { + + public CaptchaException() { + super(HttpStatus.PRECONDITION_REQUIRED); + } + + public CaptchaException(String reason) { + super(HttpStatus.PRECONDITION_REQUIRED, reason); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaFunction.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaFunction.java new file mode 100644 index 0000000000000000000000000000000000000000..acccb0af15906ea7438b0e729c253ffff17ac630 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaFunction.java @@ -0,0 +1,12 @@ +package cn.seqdata.gateway.filter.captcha; + +import com.wf.captcha.base.Captcha; + +/** + * @author jrxian + * @date 2020-11-25 12:18 + */ +@FunctionalInterface +interface CaptchaFunction { + Captcha apply(int w, int h, int l); +} \ No newline at end of file diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaGlobalFilter.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaGlobalFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..aa7ffbd4fba89602a397711aed2b14ed9617849a --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaGlobalFilter.java @@ -0,0 +1,143 @@ +package cn.seqdata.gateway.filter.captcha; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.codec.Charsets; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.CollectionUtils; +import org.springframework.web.server.ServerWebExchange; +import com.google.common.hash.Hashing; +import reactor.core.publisher.Mono; + +/** + * @author jrxian + * @date 2020-11-24 17:11 + */ +public class CaptchaGlobalFilter implements GlobalFilter, Ordered { + public static final String xCaptcha = "X-Captcha-"; + //挑战 + public static final String xCaptchaChallenge = xCaptcha + "Challenge"; + //响应 + public static final String xCaptchaResponse = xCaptcha + "Response"; + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final List properties; + private final StringRedisTemplate redisTemplate; + private final ValueOperations opsForValue; + + public CaptchaGlobalFilter(List properties, StringRedisTemplate redisTemplate) { + this.properties = properties; + this.redisTemplate = redisTemplate; + this.opsForValue = redisTemplate.opsForValue(); + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + doCaptchaFilters(exchange); + return chain.filter(exchange); + } + + @Override + public int getOrder() { + return -80; + } + + private void doCaptchaFilters(ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + + //只针对GET方法 + if(!HttpMethod.GET.equals(request.getMethod())) { + return; + } + //没有规则 + if(CollectionUtils.isEmpty(properties)) { + return; + } + + //从所有的检查列表中依次检查 + String path = path(request); + for(CaptchaItem property : properties) { + if(pathMatcher.match(property.getPath(), path)) { + doCaptchaFilter(request, property, path); + break;//只要有任何一个符合条件,检查后直接退出 + } + } + } + + private void doCaptchaFilter(ServerHttpRequest request, CaptchaItem captcha, String path) { + byte[] address = address(request); + String key = key(address, path); + + //检查是否需要输入验证码,计数器+1,定时器复位 + Long hits = opsForValue.increment(key); + redisTemplate.expire(key, captcha.getSeconds(), TimeUnit.SECONDS); + + //计数器达到阈值 + if(Objects.isNull(hits) || hits < captcha.getHits()) { + return; + } + + //不包含验证码,挑战失败 + HttpHeaders headers = request.getHeaders(); + String challenge = headers.getFirst(xCaptchaChallenge); + String response = headers.getFirst(xCaptchaResponse); + if(Objects.isNull(challenge) || Objects.isNull(response)) { + throw new CaptchaException("请输入验证码"); + } + + //回答必须和标准答案一致 + String answer = opsForValue.get(challenge); + if(!StringUtils.equalsIgnoreCase(answer, response)) { + throw new CaptchaException("验证码不正确"); + } + } + + /** + * 请求路径 + */ + private String path(ServerHttpRequest request) { + return Optional.of(request) + .map(ServerHttpRequest::getPath) + .map(PathContainer::value) + .orElse(null); + } + + /** + * 客户端地址 + */ + private byte[] address(ServerHttpRequest request) { + return Optional.of(request) + .map(ServerHttpRequest::getRemoteAddress) + .map(InetSocketAddress::getAddress) + .map(InetAddress::getAddress) + .orElse(ArrayUtils.EMPTY_BYTE_ARRAY); + } + + /** + * 用于决定是否检查的redisKey,由hash(remoteAddress+path)计算而得 + */ + private String key(byte[] address, String path) { + return Hashing.murmur3_32() + .newHasher() + .putBytes(address) + .putString(path, Charsets.ISO_8859_1) + .hash() + .toString(); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaItem.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaItem.java new file mode 100644 index 0000000000000000000000000000000000000000..b3087d506e9fefe597c107052d34748159e28c94 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaItem.java @@ -0,0 +1,13 @@ +package cn.seqdata.gateway.filter.captcha; + +/** + * @author jrxian + * @date 2020-11-25 19:07 + */ +@lombok.Getter +@lombok.Setter +public class CaptchaItem { + private String path; + private int hits; + private int seconds; +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaProperties.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..8306eb6f366e5bd7af36d64f007e5fba7dd83dd6 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaProperties.java @@ -0,0 +1,13 @@ +package cn.seqdata.gateway.filter.captcha; + +import java.util.LinkedList; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author jrxian + * @date 2020-11-26 15:43 + */ +@ConfigurationProperties(prefix = "captcha") +public class CaptchaProperties extends LinkedList { +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaType.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaType.java new file mode 100644 index 0000000000000000000000000000000000000000..2dd430d3142398e97bb7a165a717675ea9bb222b --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/captcha/CaptchaType.java @@ -0,0 +1,41 @@ +package cn.seqdata.gateway.filter.captcha; + +import com.wf.captcha.*; +import com.wf.captcha.base.Captcha; + +/** + * @author jrxian + * @date 2020-11-25 11:50 + */ +public enum CaptchaType implements CaptchaFunction { + spec { + @Override + public Captcha apply(int w, int h, int l) { + return new SpecCaptcha(w, h, l); + } + }, + gif { + @Override + public Captcha apply(int w, int h, int l) { + return new GifCaptcha(w, h, l); + } + }, + chinese { + @Override + public Captcha apply(int w, int h, int l) { + return new ChineseCaptcha(w, h, l / 2); + } + }, + chinesegif { + @Override + public Captcha apply(int w, int h, int l) { + return new ChineseGifCaptcha(w, h, l / 2); + } + }, + arithmetic { + @Override + public Captcha apply(int w, int h, int l) { + return new ArithmeticCaptcha(w, h, l / 2); + } + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/JdbcUrlRecorder.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/JdbcUrlRecorder.java new file mode 100644 index 0000000000000000000000000000000000000000..2f608863581bd5eda03760f149f2d1e3b7719493 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/JdbcUrlRecorder.java @@ -0,0 +1,14 @@ +package cn.seqdata.gateway.filter.logging; + +import org.springframework.jdbc.core.support.JdbcDaoSupport; + +/** + * Author: jrxian + * Date: 2020-02-13 09:32 + */ +public class JdbcUrlRecorder extends JdbcDaoSupport implements LogRecorder { + + @Override + public void accept(UrlRecord record) { + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/LogRecorder.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/LogRecorder.java new file mode 100644 index 0000000000000000000000000000000000000000..8f42d3aca0dc230bd645711c1f85c81a24b7bc1a --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/LogRecorder.java @@ -0,0 +1,10 @@ +package cn.seqdata.gateway.filter.logging; + +import java.util.function.Consumer; + +/** + * Author: jrxian + * Date: 2020-02-13 09:31 + */ +public interface LogRecorder extends Consumer { +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/LoggingGlobalFilter.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/LoggingGlobalFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..25c143953e498ba5bf3e7c324437289c7968d58c --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/LoggingGlobalFilter.java @@ -0,0 +1,149 @@ +package cn.seqdata.gateway.filter.logging; + +import java.net.InetSocketAddress; +import java.util.Map; +import java.util.Objects; +import java.util.StringJoiner; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.RequestPath; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import cn.seqdata.gateway.filter.authc.BearerTokenResolver; +import cn.seqdata.gateway.filter.authc.DefaultBearerTokenResolver; + +/** + * Author: jrxian + * Date: 2020-02-13 08:44 + */ +@Slf4j +@AllArgsConstructor +public class LoggingGlobalFilter implements GlobalFilter, Ordered { + private static final String REQUEST_TIME = LoggingGlobalFilter.class.getSimpleName() + ".requestTime"; + + private final BearerTokenResolver tokenResolver = new DefaultBearerTokenResolver(); + private final LogRecorder logRecorder; + private final ResourceServerTokenServices tokenServices; + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + Map attributes = exchange.getAttributes(); + attributes.put(REQUEST_TIME, System.currentTimeMillis()); + return chain.filter(exchange) + .then(Mono.defer(() -> { + UrlRecord.UrlRecordBuilder builder = UrlRecord.builder(); + + Long start = exchange.getAttribute(REQUEST_TIME); + long end = System.currentTimeMillis(); + builder.start(ObjectUtils.defaultIfNull(start, end)); + builder.end(end); + + buildRequest(builder, exchange.getRequest()); + buildResponse(builder, exchange.getResponse()); + + logRecorder.accept(builder.build()); + + return Mono.empty(); + })); + } + + @Override + public int getOrder() { + return -100; + } + + private void buildRequest(UrlRecord.UrlRecordBuilder builder, ServerHttpRequest request) { + builder.requestId(request.getId()); + + RequestPath requestPath = request.getPath(); + builder.method(request.getMethodValue()) + .host(remoteHost(request)) + .port(remotePort(request)) + .path(requestPath.value()) + .queryParams(queryParams(request.getQueryParams())); + + String accessToken = tokenResolver.resolve(request); + if(Objects.nonNull(accessToken)) { + builder.token(accessToken); + try { + OAuth2Authentication authentication = tokenServices.loadAuthentication(accessToken); + Object principal = authentication.getPrincipal(); + builder.principal(String.valueOf(principal)); + } catch(RuntimeException ignored) { + } + } + } + + private void buildResponse(UrlRecord.UrlRecordBuilder builder, ServerHttpResponse response) { + HttpStatus statusCode = response.getStatusCode(); + if(Objects.nonNull(statusCode)) { + builder.code(statusCode.value()); + } + } + + private String queryParams(MultiValueMap queryParams) { + StringJoiner joiner = new StringJoiner("&"); + + queryParams.forEach((key, vals) -> { + if(Objects.nonNull(vals)) { + if(StringUtils.containsIgnoreCase(key, "password")) { + vals.forEach(val -> joiner.add(key + "=*******")); + } else { + vals.forEach(val -> joiner.add(key + "=" + val)); + } + } else { + joiner.add(key); + } + }); + + return joiner.toString(); + } + + private String remoteHost(ServerHttpRequest request) { + HttpHeaders headers = request.getHeaders(); + String xForwardedFor = headers.getFirst(XForwardedHeadersFilter.X_FORWARDED_FOR_HEADER); + String[] strings = StringUtils.split(xForwardedFor, ','); + if(null != strings && strings.length >= 1) { + return StringUtils.trim(strings[0]); + } + + InetSocketAddress remote = request.getRemoteAddress(); + if(Objects.nonNull(remote)) { + return remote.getHostString(); + } + + return null; + } + + private int remotePort(ServerHttpRequest request) { + HttpHeaders headers = request.getHeaders(); + String xForwardedPort = headers.getFirst(XForwardedHeadersFilter.X_FORWARDED_PORT_HEADER); + if(StringUtils.isNotBlank(xForwardedPort)) { + try { + return Integer.parseInt(xForwardedPort); + } catch(NumberFormatException ex) { + InetSocketAddress remote = request.getRemoteAddress(); + if(Objects.nonNull(remote)) { + return remote.getPort(); + } + } + } + + return 0; + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/RabbitRecorder.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/RabbitRecorder.java new file mode 100644 index 0000000000000000000000000000000000000000..64fe600134fda7d67efa5f0e73fc7c39488abe76 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/RabbitRecorder.java @@ -0,0 +1,20 @@ +package cn.seqdata.gateway.filter.logging; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; + +/** + * Author: jrxian + * Date: 2020-02-15 02:22 + */ +public class RabbitRecorder implements LogRecorder { + private final RabbitTemplate rabbitTemplate; + + public RabbitRecorder(RabbitTemplate rabbitTemplate) { + this.rabbitTemplate = rabbitTemplate; + } + + @Override + public void accept(T record) { + rabbitTemplate.convertAndSend(record); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/UrlRecord.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/UrlRecord.java new file mode 100644 index 0000000000000000000000000000000000000000..e26e9704f60facba5a3d88331caf16a7e2d64560 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/logging/UrlRecord.java @@ -0,0 +1,64 @@ +package cn.seqdata.gateway.filter.logging; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +/** + * Author: jrxian + * Date: 2020-02-13 08:51 + */ +@lombok.Getter +@lombok.Setter +@lombok.Builder +@lombok.ToString +@NoArgsConstructor +@AllArgsConstructor +public class UrlRecord implements Serializable { + private static final long serialVersionUID = 1L; + + private String requestId; + + /** + * 开始时间 + */ + private long start; + /** + * 结束时间 + */ + private long end; + + /** + * 客户端主机 + */ + private String host; + /** + * 客户端端口 + */ + private int port; + + /** + * 请求方法,如GET/POST... + */ + private String method; + /** + * 请求路径 + */ + private String path; + /** + * 请求参数 + */ + private String queryParams; + /** + * 访问令牌(如果有) + */ + private String token; + /** + * 访问用户名(如果有) + */ + private String principal; + /** + * 返回状态码(HttpStatus) + */ + private int code; +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/verify/HmacVerifyGlobalFilter.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/verify/HmacVerifyGlobalFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..079fcbafcbb7bb319344371a8c0783df9628fa4e --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/verify/HmacVerifyGlobalFilter.java @@ -0,0 +1,110 @@ +package cn.seqdata.gateway.filter.verify; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import javax.crypto.Mac; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.RequestPath; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import cn.seqdata.gateway.util.GatewayAssert; +import cn.seqdata.gateway.util.MultiMapUtils; + +/** + * Author: jrxian + * Date: 2020-02-07 15:56 + */ +public class HmacVerifyGlobalFilter implements GlobalFilter, Ordered { + public static final String xCa = "X-Ca-"; + //HMac算法 + public static final String xCaHmacAlgorithms = "Hmac-Algorithms"; + //默认HMac算法 + public static final String defHmacAlgorithms = HmacAlgorithms.HMAC_SHA_256.getName(); + //签名Header + public static final String xCaSignature = xCa + "Signature"; + //签名时间戳 + public static final String xCaTimestamp = xCa + "Timestamp"; + //请求放重放Nonce + public static final String xCaNonce = xCa + "Nonce"; + //APP KEY + public static final String xCaKey = xCa + "Key"; + + private final byte[] secret; + + public HmacVerifyGlobalFilter() { + this("seqdata@13572468".getBytes()); + } + + public HmacVerifyGlobalFilter(byte[] secret) { + this.secret = secret; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + HttpHeaders headers = request.getHeaders(); + + String signature = GatewayAssert.requireNonNull(headers.getFirst(xCaSignature), "没有签名信息:" + xCaSignature); + + long timestamp = MultiMapUtils.getLong(headers, xCaTimestamp); + long delta = Math.abs(timestamp - System.currentTimeMillis()); + GatewayAssert.isTrue(delta < TimeUnit.MINUTES.toMillis(5), xCaTimestamp + "超过5分钟限制"); + + RequestPath requestPath = request.getPath(); + String hmacAlgorithms = MultiMapUtils.getOrDefault(headers, xCaHmacAlgorithms, defHmacAlgorithms); + + String[] xCaHeaderKeys = {xCaTimestamp, xCaNonce, xCaKey, ReplayDigestFunction.xCaDigest}; + SortedMap xCaHeaders = MultiMapUtils.filterKeys(headers, xCaHeaderKeys); + MultiValueMap params = request.getQueryParams(); + + String calcSign = hmac(hmacAlgorithms, secret, requestPath.value(), xCaHeaders, params); + GatewayAssert.isTrue(StringUtils.equalsIgnoreCase(signature, calcSign), "签名不符:" + xCaSignature); + + return chain.filter(exchange); + } + + @Override + public int getOrder() { + return -80; + } + + private static String hmac(String algorithm, byte[] secret, String path, + Map headers, MultiValueMap queries) { + Mac mac = HmacUtils.getInitializedMac(algorithm, secret); + + HmacUtils.updateHmac(mac, path); + updateHmac(mac, headers); + updateHmac(mac, queries); + + return Base64.encodeBase64String(mac.doFinal()); + } + + private static void updateHmac(Mac mac, Collection values) { + new TreeSet<>(values).forEach(v -> HmacUtils.updateHmac(mac, v)); + } + + private static void updateHmac(Mac mac, Map values) { + new TreeMap<>(values).forEach((k, v) -> { + HmacUtils.updateHmac(mac, k); + HmacUtils.updateHmac(mac, v); + }); + } + + private static void updateHmac(Mac mac, MultiValueMap values) { + new TreeMap<>(values).forEach((k, v) -> { + HmacUtils.updateHmac(mac, k); + updateHmac(mac, v); + }); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/verify/ReplayDigestFunction.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/verify/ReplayDigestFunction.java new file mode 100644 index 0000000000000000000000000000000000000000..1fb9d606a73a92415c76664818366143e66e1309 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/filter/verify/ReplayDigestFunction.java @@ -0,0 +1,49 @@ +package cn.seqdata.gateway.filter.verify; + +import java.security.MessageDigest; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.codec.digest.MessageDigestAlgorithms; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.reactivestreams.Publisher; +import org.springframework.cloud.gateway.filter.factory.rewrite.RewriteFunction; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import cn.seqdata.gateway.util.GatewayAssert; + +/** + * Author: jrxian + * Date: 2020-02-08 00:49 + */ +public class ReplayDigestFunction implements RewriteFunction { + //摘要算法 + public static final String xCaDigestAlgorithms = HmacVerifyGlobalFilter.xCa + "Digest-Algorithms"; + //默认摘要算法 + public static final String defDigestAlgorithms = MessageDigestAlgorithms.SHA_256; + //对内容的签名 + public static final String xCaDigest = HmacVerifyGlobalFilter.xCa + "Digest"; + + @Override + public Publisher apply(ServerWebExchange exchange, byte[] bytes) { + ServerHttpRequest request = exchange.getRequest(); + HttpHeaders headers = request.getHeaders(); + + if(headers.getContentLength() > 0 && !MediaType.APPLICATION_FORM_URLENCODED.equals(headers.getContentType())) { + String digest = GatewayAssert.requireNonNull(headers.getFirst(xCaDigest), "没有摘要信息:" + xCaDigest); + + String digestAlgorithms = ObjectUtils.defaultIfNull(headers.getFirst(xCaDigestAlgorithms), defDigestAlgorithms); + MessageDigest messageDigest = DigestUtils.getDigest(digestAlgorithms); + + String calcDigest = Base64.encodeBase64String(messageDigest.digest(bytes)); + GatewayAssert.isTrue(StringUtils.equalsIgnoreCase(digest, calcDigest), "摘要不符:" + xCaDigest); + } + + return Mono.just(bytes); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/OAuth2ClientProperties.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/OAuth2ClientProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..be5b96f51d0c99e9eaba70a90712ceebb47f1046 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/OAuth2ClientProperties.java @@ -0,0 +1,23 @@ +package cn.seqdata.gateway.swagger; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Author: jrxian + * Date: 2020-02-04 09:36 + */ +@lombok.Getter +@lombok.Setter +@ConfigurationProperties(prefix = "security.oauth2.client") +public class OAuth2ClientProperties { + + /** + * OAuth2 client id. + */ + private String clientId; + + /** + * OAuth2 client secret. A random secret is generated by default. + */ + private String clientSecret; +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/SwaggerController.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/SwaggerController.java new file mode 100644 index 0000000000000000000000000000000000000000..1462fc18ba8f8bc898c19bb5d4e0ee1cf61664d9 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/SwaggerController.java @@ -0,0 +1,19 @@ +package cn.seqdata.gateway.swagger; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.reactive.result.view.RedirectView; + +/** + * 默认首页跳转到swagger调试页面 + */ +@Controller +@RequestMapping +public class SwaggerController { + + @GetMapping("/") + public RedirectView home() { + return new RedirectView("/swagger-ui.html"); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/SwaggerHandler.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/SwaggerHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..bdc860c4fca951f6d7aca457110ff03b109e7222 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/SwaggerHandler.java @@ -0,0 +1,64 @@ +package cn.seqdata.gateway.swagger; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; +import springfox.documentation.swagger.web.*; + +/** + * 为适应spring 5.x的reactive模式,原servlet模式的swagger不再有效 + */ +@RestController +@RequestMapping("/swagger-resources") +public class SwaggerHandler { + private final SwaggerResourcesProvider swaggerResources; + private SecurityConfiguration securityConfiguration; + private UiConfiguration uiConfiguration; + + @Value("${spring.application.name:webapp}") + private String appName; + + @Autowired + public SwaggerHandler(OAuth2ClientProperties clientProperties, SwaggerResourcesProvider swaggerResources) { + this.swaggerResources = swaggerResources; + + securityConfiguration = SecurityConfigurationBuilder.builder() + .appName(appName) + .clientId(clientProperties.getClientId()) + .clientSecret(clientProperties.getClientSecret()) + .build(); + + uiConfiguration = UiConfigurationBuilder.builder() + .build(); + } + + @Autowired(required = false) + public void setSecurityConfiguration(SecurityConfiguration securityConfiguration) { + this.securityConfiguration = securityConfiguration; + } + + @Autowired(required = false) + public void setUiConfiguration(UiConfiguration uiConfiguration) { + this.uiConfiguration = uiConfiguration; + } + + @GetMapping("/configuration/security") + public Mono securityConfiguration() { + return Mono.just(securityConfiguration); + } + + @GetMapping("/configuration/ui") + public Mono uiConfiguration() { + return Mono.just(uiConfiguration); + } + + @GetMapping + public Mono> swaggerResources() { + return Mono.just(swaggerResources.get()); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/SwaggerProvider.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/SwaggerProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..e36a9a78b329e0429160a4321cb6ef4400b02e85 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/swagger/SwaggerProvider.java @@ -0,0 +1,85 @@ +package cn.seqdata.gateway.swagger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.gateway.route.RouteDefinition; +import org.springframework.cloud.gateway.route.RouteDefinitionLocator; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import lombok.SneakyThrows; +import springfox.documentation.swagger.web.SwaggerResource; +import springfox.documentation.swagger.web.SwaggerResourcesProvider; + +/** + * Author: jrxian + * Date: 2020-02-03 22:50 + */ +@Primary +@Component +public class SwaggerProvider implements SwaggerResourcesProvider { + @Value("${spring.application.name:webapp}") + private String serviceId; + + private RouteDefinitionLocator locator; + + @Autowired + public void setLocator(RouteDefinitionLocator locator) { + this.locator = locator; + } + + @SneakyThrows({InterruptedException.class, ExecutionException.class}) + @Override + public List get() { + return CompletableFuture.supplyAsync(() -> { + List swaggerResources = new ArrayList<>(); + + locator.getRouteDefinitions() + .toStream() + //过滤掉网关本身 + .filter(route -> !StringUtils.equals(routeId(route), serviceId)) + .forEach(route -> route.getPredicates() + .stream() + .filter(predicateDefinition -> "Path".equals(predicateDefinition.getName())) + .findFirst() + .ifPresent(predicate -> { + String routeId = routeId(route); + Map args = predicate.getArgs(); + String pattern = args.get("pattern"); + String location = StringUtils.replace(pattern, "/**", "/v2/api-docs"); + if(StringUtils.isBlank(location)) { + return; + } + swaggerResources.add(swaggerResource(routeId, location)); + })); + + return swaggerResources; + }) + .get(); + } + + private String routeId(RouteDefinition routeDefinition) { + return Optional.of(routeDefinition.getId()) + .map(x -> StringUtils.removeStart(x, "CompositeDiscoveryClient_")) + .map(x -> StringUtils.removeStart(x, "ReactiveCompositeDiscoveryClient_")) + .get(); + } + + private SwaggerResource swaggerResource(String name, String location) { + SwaggerResource swaggerResource = new SwaggerResource(); + + swaggerResource.setName(name); + swaggerResource.setSwaggerVersion("2.0"); + swaggerResource.setLocation(location); + + return swaggerResource; + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/util/GatewayAssert.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/util/GatewayAssert.java new file mode 100644 index 0000000000000000000000000000000000000000..45376711dec13bbb6895d99904244addcba10856 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/util/GatewayAssert.java @@ -0,0 +1,35 @@ +package cn.seqdata.gateway.util; + +import java.util.Objects; +import java.util.function.Supplier; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** + * Author: jrxian + * Date: 2020-02-08 01:56 + */ +public class GatewayAssert { + + public static T requireNonNull(T object, String message) { + if(Objects.isNull(object)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return object; + } + + public static T requireNonNull(T object, Supplier messageSupplier) { + return requireNonNull(object, messageSupplier.get()); + } + + public static void isTrue(boolean expression, String message) { + if(!expression) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + } + + public static void isTrue(boolean expression, Supplier messageSupplier) { + isTrue(expression, messageSupplier.get()); + } +} diff --git a/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/util/MultiMapUtils.java b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/util/MultiMapUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..dfa532651b4edea6c7c5f5c509e1bf5f9a21fe59 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/java/cn/seqdata/gateway/util/MultiMapUtils.java @@ -0,0 +1,38 @@ +package cn.seqdata.gateway.util; + +import java.util.Collections; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +import org.apache.commons.lang3.math.NumberUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; + +/** + * Author: jrxian + * Date: 2020-02-08 01:32 + */ +public class MultiMapUtils { + + public static String getOrDefault(MultiValueMap multiMap, String key, String defaultValue) { + return CollectionUtils.lastElement(multiMap.getOrDefault(key, Collections.singletonList(defaultValue))); + } + + public static long getLong(MultiValueMap multiMap, String key) { + return NumberUtils.toLong(multiMap.getFirst(key)); + } + + public static SortedMap filterKeys(MultiValueMap multiMap, String... keys) { + SortedMap filterMap = new TreeMap<>(); + + for(String key : keys) { + String value = multiMap.getFirst(key); + if(Objects.nonNull(value)) { + filterMap.put(key, value); + } + } + + return filterMap; + } +} diff --git a/seqdata-cloud-gateway/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/seqdata-cloud-gateway/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..3c6fecc8b5339f3f7cec40b7a0cf25ba3e8c9a5b --- /dev/null +++ b/seqdata-cloud-gateway/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,29 @@ +{ + "properties": [ + { + "name": "spring.cloud.gateway.ignoring", + "type": "java.lang.String", + "description": "Description for spring.cloud.gateway.ignoring." + }, + { + "name": "spring.cloud.gateway.logging.enabled", + "type": "java.lang.String", + "description": "Description for spring.cloud.gateway.logging.enabled." + }, + { + "name": "spring.cloud.gateway.path-matcher.enabled", + "type": "java.lang.String", + "description": "Description for spring.cloud.gateway.path-matcher.enabled." + }, + { + "name": "spring.cloud.gateway.hmac-verify.enabled", + "type": "java.lang.String", + "description": "Description for spring.cloud.gateway.hmac-verify.enabled." + }, + { + "name": "security.oauth2.resource.ignoring", + "type": "java.lang.String", + "description": "Description for security.oauth2.resource.ignoring." + } + ] +} \ No newline at end of file diff --git a/seqdata-cloud-gateway/src/main/resources/application.yml b/seqdata-cloud-gateway/src/main/resources/application.yml new file mode 100644 index 0000000000000000000000000000000000000000..079f90429850257d83852bd31eff7dac89fc3f70 --- /dev/null +++ b/seqdata-cloud-gateway/src/main/resources/application.yml @@ -0,0 +1,39 @@ +logging: + level: + root: info + cn.seqdata: debug + com.netflix: warn + com.alibaba.nacos: warn + org.springframework: warn + io.swagger.models.parameters.AbstractSerializableParameter: error +spring: + application: + name: gateway + profiles: + active: logging, jackson + cloud: + gateway: + globalcors: + cors-configurations: + '[/**]': + allow-credentials: true + allowed-origins: "*" + allowed-headers: "*" + allowed-methods: GET, PUT, POST, PATCH, DELETE + logging: + enabled: true + path-matcher: + enabled: true + hmac-verify: + enabled: false +security: + oauth2: + client: + client-id: client + client-secret: secret + resource: + token-info-uri: http://authz/oauth/check_token + prefer-token-info: true + ignoring: + - /authc/** + - /authz/** \ No newline at end of file diff --git a/seqdata-cloud-gateway/src/main/resources/bootstrap.yml b/seqdata-cloud-gateway/src/main/resources/bootstrap.yml new file mode 100644 index 0000000000000000000000000000000000000000..469b3a24b97bc0c681871d09788bfb79b78bc94f --- /dev/null +++ b/seqdata-cloud-gateway/src/main/resources/bootstrap.yml @@ -0,0 +1,18 @@ +spring: + cloud: + gateway: + discovery: + locator: + enabled: true + nacos: + discovery: + server-addr: ${NACOS_SERVER_IP:nacos.seqdata.cn}:${NACOS_SERVER_PORT:8848} + cluster-name: ${NACOS_CLUSTER_NAME:DEFAULT} + namespace: ${NACOS_NAMESPACE:public} + group: ${NACOS_GROUP:DEFAULT_GROUP} + config: + server-addr: ${spring.cloud.nacos.discovery.server-addr} + cluster-name: ${spring.cloud.nacos.discovery.cluster-name} + namespace: ${spring.cloud.nacos.discovery.namespace} + group: ${spring.cloud.nacos.discovery.group} + file-extension: ${NACOS_FILE_EXT:yml} \ No newline at end of file