OAuth2是一个关于授权的开放标准,核心思路是通过各类认证手段(具体什么手段OAuth2不关心)认证用户身份,并颁发token(令牌),使得第三方应用可以使用该token(令牌)在限定时间、限定范围访问指定资源。

  OAuth2中使用token验证用户登录合法性,但token最大的问题是不携带用户信息,资源服务器无法在本地进行验证,每次对于资源的访问,资源服务器都需要向认证服务器发起请求,一是验证token的有效性,二是获取token对应的用户信息。如果有大量的此类请求,无疑处理效率是很低,且认证服务器会变成一个中心节点,这在分布式架构下很影响性能。如果认证服务器颁发的是jwt格式的token,那么资源服务器就可以直接自己验证token的有效性并绑定用户,这无疑大大提升了处理效率且减少了单点隐患。

  SpringCloud认证授权解决思路:认证服务负责认证,网关负责校验认证和鉴权,其他API服务负责处理自己的业务逻辑。安全相关的逻辑只存在于认证服务和网关服务中,其他服务只是单纯地提供服务而没有任何安全相关逻辑。

微服务鉴权功能划分:

  • gitegg-oauth:Oauth2用户认证和单点登录
  • gitegg-gateway:请求转发和统一鉴权
  • gitegg-system: 读取系统配置的RBAC权限配置并存放到缓存

一、鉴权配置

1、GitEgg-Platform工程下新建gitegg-platform-oauth2工程,用于统一管理OAuth2版本,及统一配置

<!--?xml version="1.0" encoding="UTF-8"?-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemalocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactid>GitEgg-Platform</artifactid>
<groupid>com.gitegg.platform</groupid>
<version>1.0-SNAPSHOT</version>
</parent>
<modelversion>4.0.0</modelversion> <artifactid>gitegg-platform-oauth2</artifactid>
<name>${project.artifactId}</name>
<packaging>jar</packaging> <dependencies>
<dependency>
<groupid>org.springframework.boot</groupid>
<artifactid>spring-boot-configuration-processor</artifactid>
</dependency>
<dependency>
<groupid>org.springframework.cloud</groupid>
<artifactid>spring-cloud-starter-oauth2</artifactid>
</dependency>
<dependency>
<groupid>org.springframework.security</groupid>
<artifactid>spring-security-oauth2-jose</artifactid>
</dependency>
<dependency>
<groupid>org.springframework.security</groupid>
<artifactid>spring-security-oauth2-resource-server</artifactid>
</dependency>
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-swagger</artifactid>
<optional>true</optional>
</dependency>
</dependencies>
</project>

2、在gitegg-oauth工程中引入需要的库

<!--?xml version="1.0" encoding="UTF-8"?-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemalocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactid>GitEgg-Cloud</artifactid>
<groupid>com.gitegg.cloud</groupid>
<version>1.0-SNAPSHOT</version>
</parent>
<modelversion>4.0.0</modelversion> <artifactid>gitegg-oauth</artifactid>
<name>${project.artifactId}</name>
<packaging>jar</packaging> <dependencies>
<!-- gitegg-platform-boot -->
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-boot</artifactid>
<version>${gitegg.project.version}</version>
</dependency>
<!-- gitegg-platform-cloud -->
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-cloud</artifactid>
<version>${gitegg.project.version}</version>
</dependency>
<!-- gitegg-platform-oauth2 -->
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-oauth2</artifactid>
<version>${gitegg.project.version}</version>
</dependency>
<!-- gitegg数据库驱动及连接池 -->
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-db</artifactid>
</dependency>
<!-- gitegg mybatis-plus -->
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-mybatis</artifactid>
</dependency>
<!-- 验证码 -->
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-captcha</artifactid>
</dependency>
<!-- gitegg-service-system 的fegin公共调用方法 -->
<dependency>
<groupid>com.gitegg.cloud</groupid>
<artifactid>gitegg-service-system-api</artifactid>
<version>${gitegg.project.version}</version>
</dependency>
<dependency>
<groupid>org.apache.tomcat.embed</groupid>
<artifactid>tomcat-embed-core</artifactid>
</dependency>
<dependency>
<groupid>org.springframework.boot</groupid>
<artifactid>spring-boot-starter-data-redis</artifactid>
</dependency>
</dependencies> </project>

3、JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。首先我们使用keytool生成RSA证书gitegg.jks,复制到gitegg-oauth工程的resource目录下,CMD命令行进入到JDK安装目录的bin目录下, 使用keytool命令生成gitegg.jks证书

keytool -genkey -alias gitegg -keyalg RSA -keystore gitegg.jks

4、新建GitEggUserDetailsServiceImpl.java实现SpringSecurity获取用户信息接口,用于SpringSecurity鉴权时获取用户信息

package com.gitegg.oauth.service;

import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.common.exceptions.UserDeniedAuthorizationException;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils; import com.gitegg.oauth.enums.AuthEnum;
import com.gitegg.platform.base.constant.AuthConstant;
import com.gitegg.platform.base.domain.GitEggUser;
import com.gitegg.platform.base.enums.ResultCodeEnum;
import com.gitegg.platform.base.result.Result;
import com.gitegg.service.system.api.feign.IUserFeign; import cn.hutool.core.bean.BeanUtil;
import lombok.RequiredArgsConstructor; /**
* 实现SpringSecurity获取用户信息接口
*
* @author gitegg
*/
@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class GitEggUserDetailsServiceImpl implements UserDetailsService { private final IUserFeign userFeign; private final HttpServletRequest request; @Override
public GitEggUserDetails loadUserByUsername(String username) { // 获取登录类型,密码,二维码,验证码
String authLoginType = request.getParameter(AuthConstant.AUTH_TYPE); // 获取客户端id
String clientId = request.getParameter(AuthConstant.AUTH_CLIENT_ID); // 远程调用返回数据
Result<object> result; // 通过手机号码登录
if (!StringUtils.isEmpty(authLoginType) && AuthEnum.PHONE.code.equals(authLoginType))
{
String phone = request.getParameter(AuthConstant.PHONE_NUMBER);
result = userFeign.queryUserByPhone(phone);
}
// 通过账号密码登录
else if(!StringUtils.isEmpty(authLoginType) && AuthEnum.QR.code.equals(authLoginType))
{
result = userFeign.queryUserByAccount(username);
}
else
{
result = userFeign.queryUserByAccount(username);
} // 判断返回信息
if (null != result && result.isSuccess()) {
GitEggUser gitEggUser = new GitEggUser();
BeanUtil.copyProperties(result.getData(), gitEggUser, false);
if (gitEggUser == null || gitEggUser.getId() == null) {
throw new UsernameNotFoundException(ResultCodeEnum.INVALID_USERNAME.msg);
} if (CollectionUtils.isEmpty(gitEggUser.getRoleIdList())) {
throw new UserDeniedAuthorizationException(ResultCodeEnum.INVALID_ROLE.msg);
} return new GitEggUserDetails(gitEggUser.getId(), gitEggUser.getTenantId(), gitEggUser.getOauthId(),
gitEggUser.getNickname(), gitEggUser.getRealName(), gitEggUser.getOrganizationId(),
gitEggUser.getOrganizationName(),
gitEggUser.getOrganizationIds(), gitEggUser.getOrganizationNames(), gitEggUser.getRoleId(), gitEggUser.getRoleIds(), gitEggUser.getRoleName(), gitEggUser.getRoleNames(),
gitEggUser.getRoleIdList(), gitEggUser.getRoleKeyList(), gitEggUser.getResourceKeyList(),
gitEggUser.getDataPermission(),
gitEggUser.getAvatar(), gitEggUser.getAccount(), gitEggUser.getPassword(), true, true, true, true,
AuthorityUtils.createAuthorityList(gitEggUser.getRoleIdList().toArray(new String[gitEggUser.getRoleIdList().size()])));
} else {
throw new UsernameNotFoundException(result.getMsg());
}
} }

5、新建AuthorizationServerConfig.java用于认证服务相关配置,正式环境请一定记得修改gitegg.jks配置的密码,这里默认为123456。TokenEnhancer 为登录用户的扩展信息,可以自己定义。

package com.gitegg.oauth.config;

import java.security.KeyPair;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map; import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
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.TokenGranter;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory; import com.anji.captcha.service.CaptchaService;
import com.gitegg.oauth.granter.GitEggTokenGranter;
import com.gitegg.oauth.service.GitEggClientDetailsServiceImpl;
import com.gitegg.oauth.service.GitEggUserDetails;
import com.gitegg.platform.base.constant.AuthConstant;
import com.gitegg.platform.base.constant.TokenConstant;
import com.gitegg.service.system.api.feign.IUserFeign; import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows; /**
* 认证服务配置
*/
@Configuration
@EnableAuthorizationServer
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { private final DataSource dataSource; private final AuthenticationManager authenticationManager; private final UserDetailsService userDetailsService; private final IUserFeign userFeign; private final RedisTemplate redisTemplate; private final CaptchaService captchaService; @Value("${captcha.type}")
private String captchaType; /**
* 客户端信息配置
*/
@Override
@SneakyThrows
public void configure(ClientDetailsServiceConfigurer clients) {
GitEggClientDetailsServiceImpl jdbcClientDetailsService = new GitEggClientDetailsServiceImpl(dataSource);
jdbcClientDetailsService.setFindClientDetailsSql(AuthConstant.FIND_CLIENT_DETAILS_SQL);
jdbcClientDetailsService.setSelectClientDetailsSql(AuthConstant.SELECT_CLIENT_DETAILS_SQL);
clients.withClientDetails(jdbcClientDetailsService);
} /**
* 配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) { TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
List<tokenenhancer> tokenEnhancers = new ArrayList<>();
tokenEnhancers.add(tokenEnhancer());
tokenEnhancers.add(jwtAccessTokenConverter());
tokenEnhancerChain.setTokenEnhancers(tokenEnhancers); // 获取自定义tokenGranter
TokenGranter tokenGranter = GitEggTokenGranter.getTokenGranter(authenticationManager, endpoints, redisTemplate,
userFeign, captchaService, captchaType); endpoints.authenticationManager(authenticationManager)
.accessTokenConverter(jwtAccessTokenConverter())
.tokenEnhancer(tokenEnhancerChain)
.userDetailsService(userDetailsService)
.tokenGranter(tokenGranter)
/**
*
* refresh_token有两种使用方式:重复使用(true)、非重复使用(false),默认为true
* 1.重复使用:access_token过期刷新时, refresh token过期时间未改变,仍以初次生成的时间为准
* 2.非重复使用:access_token过期刷新时, refresh_token过期时间延续,在refresh_token有效期内刷新而无需失效再次登录
*/
.reuseRefreshTokens(false);
} /**
* 允许表单认证
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.allowFormAuthenticationForClients()
.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()");
} /**
* 使用非对称加密算法对token签名
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(keyPair());
return converter;
} /**
* 从classpath下的密钥库中获取密钥对(公钥+私钥)
*/
@Bean
public KeyPair keyPair() {
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(
new ClassPathResource("gitegg.jks"), "123456".toCharArray());
KeyPair keyPair = factory.getKeyPair(
"gitegg", "123456".toCharArray());
return keyPair;
} /**
* JWT内容增强
*/
@Bean
public TokenEnhancer tokenEnhancer() {
return (accessToken, authentication) -> {
Map<string, object=""> map = new HashMap<>(2);
GitEggUserDetails user = (GitEggUserDetails) authentication.getUserAuthentication().getPrincipal();
map.put(TokenConstant.TENANT_ID, user.getTenantId());
map.put(TokenConstant.OAUTH_ID, user.getOauthId());
map.put(TokenConstant.USER_ID, user.getId());
map.put(TokenConstant.ORGANIZATION_ID, user.getOrganizationId());
map.put(TokenConstant.ORGANIZATION_NAME, user.getOrganizationName());
map.put(TokenConstant.ORGANIZATION_IDS, user.getOrganizationIds());
map.put(TokenConstant.ORGANIZATION_NAMES, user.getOrganizationNames());
map.put(TokenConstant.ROLE_ID, user.getRoleId());
map.put(TokenConstant.ROLE_NAME, user.getRoleName());
map.put(TokenConstant.ROLE_IDS, user.getRoleIds());
map.put(TokenConstant.ROLE_NAMES, user.getRoleNames());
map.put(TokenConstant.ACCOUNT, user.getAccount());
map.put(TokenConstant.REAL_NAME, user.getRealName());
map.put(TokenConstant.NICK_NAME, user.getNickname());
map.put(TokenConstant.ROLE_ID_LIST, user.getRoleIdList());
map.put(TokenConstant.ROLE_KEY_LIST, user.getRoleKeyList());
//不把权限菜单放到jwt里面,当菜单太多时,会导致jwt长度不可控
// map.put(TokenConstant.RESOURCE_KEY_LIST, user.getResourceKeyList());
map.put(TokenConstant.DATA_PERMISSION, user.getDataPermission());
map.put(TokenConstant.AVATAR, user.getAvatar());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
return accessToken;
};
}
}

6、Gateway在认证授权时需要RSA的公钥来验证签名是否合法,所以这里新建GitEggOAuthController的getKey接口用于Gateway获取RSA公钥

    @GetMapping("/public_key")
public Map<string, object=""> getKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}

7、新建ResourceServerConfig.java资源服务器配置,放开public_key的读取权限

	@Override
@SneakyThrows
public void configure(HttpSecurity http) {
http.headers().frameOptions().disable();
http.formLogin()
.and()
.authorizeRequests().requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.and()
.authorizeRequests()
.antMatchers(
"/oauth/public_key").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}

8、在gitegg-service-system新建InitResourceRolesCacheRunner.java实现CommandLineRunner接口,用于系统启动时加载RBAC权限配置信息到缓存

package com.gitegg.service.system.component;

import java.util.*;
import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component; import com.gitegg.platform.base.constant.AuthConstant;
import com.gitegg.service.system.entity.Resource;
import com.gitegg.service.system.service.IResourceService; import cn.hutool.core.collection.CollectionUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; /**
* 容器启动完成加载资源权限数据到缓存
*/
@Slf4j
@RequiredArgsConstructor(onConstructor_ = @Autowired)
@Component
public class InitResourceRolesCacheRunner implements CommandLineRunner { private final RedisTemplate redisTemplate; private final IResourceService resourceService; /**
* 是否开启租户模式
*/
@Value(("${tenant.enable}"))
private Boolean enable; @Override
public void run(String... args) { log.info("InitResourceRolesCacheRunner running"); // 查询系统角色和权限的关系
List<resource> resourceList = resourceService.queryResourceRoleIds(); // 判断是否开启了租户模式,如果开启了,那么角色权限需要按租户进行分类存储
if (enable) {
Map<long, list<resource="">> resourceListMap =
resourceList.stream().collect(Collectors.groupingBy(Resource::getTenantId));
resourceListMap.forEach((key, value) -> {
String redisKey = AuthConstant.TENANT_RESOURCE_ROLES_KEY + key;
redisTemplate.delete(redisKey);
addRoleResource(redisKey, value);
System.out.println(redisTemplate.opsForHash().entries(redisKey).size());
});
} else {
redisTemplate.delete(AuthConstant.RESOURCE_ROLES_KEY);
addRoleResource(AuthConstant.RESOURCE_ROLES_KEY, resourceList);
}
} private void addRoleResource(String key, List<resource> resourceList) {
Map<string, list<string="">> resourceRolesMap = new TreeMap<>();
Optional.ofNullable(resourceList).orElse(new ArrayList<>()).forEach(resource -> {
// roleId -> ROLE_{roleId}
List<string> roles = Optional.ofNullable(resource.getRoleIds()).orElse(new ArrayList<>()).stream()
.map(roleId -> AuthConstant.AUTHORITY_PREFIX + roleId).collect(Collectors.toList());
if (CollectionUtil.isNotEmpty(roles)) {
resourceRolesMap.put(resource.getResourceUrl(), roles);
}
});
redisTemplate.opsForHash().putAll(key, resourceRolesMap);
}
}

9、新建网关服务gitegg-gateway,作为Oauth2的资源服务、客户端服务使用,对访问微服务的请求进行转发、统一校验认证和鉴权操作,引入相关依赖

<!--?xml version="1.0" encoding="UTF-8"?-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemalocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactid>GitEgg-Cloud</artifactid>
<groupid>com.gitegg.cloud</groupid>
<version>1.0-SNAPSHOT</version>
</parent>
<modelversion>4.0.0</modelversion> <artifactid>gitegg-gateway</artifactid> <dependencies>
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-base</artifactid>
<version>${gitegg.project.version}</version>
</dependency>
<!-- Nacos 服务注册发现 -->
<dependency>
<groupid>com.alibaba.cloud</groupid>
<artifactid>spring-cloud-starter-alibaba-nacos-discovery</artifactid>
</dependency>
<!-- Nacos 分布式配置 -->
<dependency>
<groupid>com.alibaba.cloud</groupid>
<artifactid>spring-cloud-starter-alibaba-nacos-config</artifactid>
</dependency>
<!-- OpenFeign 微服务调用解决方案 -->
<dependency>
<groupid>org.springframework.cloud</groupid>
<artifactid>spring-cloud-starter-openfeign</artifactid>
</dependency>
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-oauth2</artifactid>
<version>${gitegg.project.version}</version>
</dependency>
<!-- gitegg cache自定义扩展 -->
<dependency>
<groupid>com.gitegg.platform</groupid>
<artifactid>gitegg-platform-cache</artifactid>
<version>${gitegg.project.version}</version>
</dependency>
<dependency>
<groupid>org.springframework.cloud</groupid>
<artifactid>spring-cloud-starter-gateway</artifactid>
</dependency>
<dependency>
<groupid>io.springfox</groupid>
<artifactid>springfox-swagger2</artifactid>
</dependency>
<dependency>
<groupid>com.github.xiaoymin</groupid>
<artifactid>knife4j-spring-ui</artifactid>
</dependency>
</dependencies> </project>

10、新建AuthResourceServerConfig.java对gateway网关服务进行配置安全配置,需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因为SpringCloud Gateway基于WebFlux

package com.gitegg.gateway.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain; import com.gitegg.gateway.auth.AuthorizationManager;
import com.gitegg.gateway.filter.WhiteListRemoveJwtFilter;
import com.gitegg.gateway.handler.AuthServerAccessDeniedHandler;
import com.gitegg.gateway.handler.AuthServerAuthenticationEntryPoint;
import com.gitegg.gateway.props.AuthUrlWhiteListProperties;
import com.gitegg.platform.base.constant.AuthConstant; import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import reactor.core.publisher.Mono; /**
* 资源服务器配置
*/
@AllArgsConstructor
@Configuration
// 注解需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因为SpringCloud Gateway基于WebFlux
@EnableWebFluxSecurity
public class AuthResourceServerConfig { private final AuthorizationManager authorizationManager; private final AuthServerAccessDeniedHandler authServerAccessDeniedHandler; private final AuthServerAuthenticationEntryPoint authServerAuthenticationEntryPoint; private final AuthUrlWhiteListProperties authUrlWhiteListProperties; private final WhiteListRemoveJwtFilter whiteListRemoveJwtFilter; @Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http.oauth2ResourceServer().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
// 自定义处理JWT请求头过期或签名错误的结果
http.oauth2ResourceServer().authenticationEntryPoint(authServerAuthenticationEntryPoint);
// 对白名单路径,直接移除JWT请求头,不移除的话,后台会校验jwt
http.addFilterBefore(whiteListRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
http.authorizeExchange()
.pathMatchers(ArrayUtil.toArray(authUrlWhiteListProperties.getUrls(), String.class)).permitAll()
.anyExchange().access(authorizationManager)
.and()
.exceptionHandling()
.accessDeniedHandler(authServerAccessDeniedHandler) // 处理未授权
.authenticationEntryPoint(authServerAuthenticationEntryPoint) //处理未认证
.and()
.cors()
.and().csrf().disable(); return http.build();
} /**
* ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication,需要把jwt的Claim中的authorities加入
* 解决方案:重新定义ReactiveAuthenticationManager权限管理器,默认转换器JwtGrantedAuthoritiesConverter
*/
@Bean
public Converter<jwt, ?="" extends="" mono<?="" abstractauthenticationtoken="">> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstant.AUTHORITY_PREFIX);
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstant.AUTHORITY_CLAIM_NAME); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
}

11、新建AuthorizationManager.java实现ReactiveAuthorizationManager接口,用于自定义权限校验

package com.gitegg.gateway.auth;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.util.StringUtils; import com.gitegg.platform.base.constant.AuthConstant; import cn.hutool.core.convert.Convert;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono; /**
* 网关鉴权管理器
*/
@Slf4j
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class AuthorizationManager implements ReactiveAuthorizationManager<authorizationcontext> { private final RedisTemplate redisTemplate; /**
* 是否开启租户模式
*/
@Value(("${tenant.enable}"))
private Boolean enable; @Override
public Mono<authorizationdecision> check(Mono<authentication> mono, AuthorizationContext authorizationContext) {
ServerHttpRequest request = authorizationContext.getExchange().getRequest();
String path = request.getURI().getPath();
PathMatcher pathMatcher = new AntPathMatcher(); // 对应跨域的预检请求直接放行
if (request.getMethod() == HttpMethod.OPTIONS) {
return Mono.just(new AuthorizationDecision(true));
} // token为空拒绝访问
String token = request.getHeaders().getFirst(AuthConstant.JWT_TOKEN_HEADER);
if (StringUtils.isEmpty(token)) {
return Mono.just(new AuthorizationDecision(false));
} // 如果开启了租户模式,但是请求头里没有租户信息,那么拒绝访问
String tenantId = request.getHeaders().getFirst(AuthConstant.TENANT_ID);
if (enable && StringUtils.isEmpty(tenantId)) {
return Mono.just(new AuthorizationDecision(false));
} String redisRoleKey = AuthConstant.TENANT_RESOURCE_ROLES_KEY;
// 判断是否开启了租户模式,如果开启了,那么按租户分类的方式获取角色权限
if (enable) {
redisRoleKey += tenantId;
} else {
redisRoleKey = AuthConstant.RESOURCE_ROLES_KEY;
} // 缓存取资源权限角色关系列表
Map<object, object=""> resourceRolesMap = redisTemplate.opsForHash().entries(redisRoleKey);
Iterator<object> iterator = resourceRolesMap.keySet().iterator(); //请求路径匹配到的资源需要的角色权限集合authorities统计
List<string> authorities = new ArrayList<>();
while (iterator.hasNext()) {
String pattern = (String) iterator.next();
if (pathMatcher.match(pattern, path)) {
authorities.addAll(Convert.toList(String.class, resourceRolesMap.get(pattern)));
}
}
Mono<authorizationdecision> authorizationDecisionMono = mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(roleId -> {
// roleId是请求用户的角色(格式:ROLE_{roleId}),authorities是请求资源所需要角色的集合
log.info("访问路径:{}", path);
log.info("用户角色roleId:{}", roleId);
log.info("资源需要权限authorities:{}", authorities);
return authorities.contains(roleId);
})
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
return authorizationDecisionMono;
}
}

12、新建AuthGlobalFilter.java全局过滤器,解析用户请求信息,将用户信息及租户信息放在请求的Header中,这样后续服务就不需要解析JWT令牌了,可以直接从请求的Header中获取到用户和租户信息。

package com.gitegg.gateway.filter;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.text.ParseException;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer; import org.springframework.beans.factory.annotation.Value;
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.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange; import com.gitegg.platform.base.constant.AuthConstant;
import com.nimbusds.jose.JWSObject; import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono; /**
* 将登录用户的JWT转化成用户信息的全局过滤器
*/
@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered { /**
* 是否开启租户模式
*/
@Value(("${tenant.enable}"))
private Boolean enable; @Override
public Mono<void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String tenantId = exchange.getRequest().getHeaders().getFirst(AuthConstant.TENANT_ID); String token = exchange.getRequest().getHeaders().getFirst(AuthConstant.JWT_TOKEN_HEADER); if (StrUtil.isEmpty(tenantId) && StrUtil.isEmpty(token)) {
return chain.filter(exchange);
} Map<string, string=""> addHeaders = new HashMap<>(); // 如果系统配置已开启租户模式,设置tenantId
if (enable && StrUtil.isEmpty(tenantId)) {
addHeaders.put(AuthConstant.TENANT_ID, tenantId);
} if (!StrUtil.isEmpty(token)) {
try {
//从token中解析用户信息并设置到Header中去
String realToken = token.replace("Bearer ", "");
JWSObject jwsObject = JWSObject.parse(realToken);
String userStr = jwsObject.getPayload().toString();
log.info("AuthGlobalFilter.filter() User:{}", userStr);
addHeaders.put(AuthConstant.HEADER_USER, URLEncoder.encode(userStr, "UTF-8")); } catch (ParseException | UnsupportedEncodingException e) {
e.printStackTrace();
}
} Consumer<httpheaders> httpHeaders = httpHeader -> {
addHeaders.forEach((k, v) -> {
httpHeader.set(k, v);
});
}; ServerHttpRequest request = exchange.getRequest().mutate().headers(httpHeaders).build();
exchange = exchange.mutate().request(request).build();
return chain.filter(exchange);
} @Override
public int getOrder() {
return 0;
}
}

13、在Nacos中添加权限相关配置信息:

spring:
jackson:
time-zone: Asia/Shanghai
date-format: yyyy-MM-dd HH:mm:ss
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: 'http://127.0.0.1/gitegg-oauth/oauth/public_key'
# 多租户配置
tenant:
# 是否开启租户模式
enable: true
# 需要排除的多租户的表
exclusionTable:
- "t_sys_district"
- "t_sys_tenant"
- "t_sys_role"
- "t_sys_resource"
- "t_sys_role_resource"
# 租户字段名称
column: tenant_id
# 网关放行白名单,配置白名单路径
white-list:
urls:
- "/gitegg-oauth/oauth/public_key"

二、注销登录使JWT失效

因为JWT是无状态的且不在服务端储存,所以,当系统在执行退出登录时就无法使JWT失效,我们有两种方式拒绝注销登录后的JWT:

  • JWT白名单:每次登录成功就将JWT存到缓存中,缓存有效期和JWT有效期保持一致,注销登录就将JWT从缓存中移出。Gateway每次认证授权先从缓存JWT白名单中获取是否存在该JWT,存在则继续校验,不存在则拒绝访问。

  • JWT黑名单:每当注销登录时,将JWT存到缓存中,解析JWT的到期时间,将缓存过期时间设置为和JWT一致。Gateway每次认证授权先从缓存中获取JWT是否存在于黑名单中,存在则拒绝访问,不存在则继续校验。

不管是白名单还是黑名单,实现方式的原理都基本一致,就是将JWT先存放到缓存,再根据不同的状态进行判断JWT是否有效,下面是两种方式的优缺点分析:

  • 黑名单功能分析:优点是存放到缓存的数据量将小于白名单方式存放的数据量,缺点是无法获知当前签发了多少JWT,当前在线多少登录用户。
  • 白名单功能分析:优点是当我们需要统计在线用户的时候,白名单方式可以近似的获取到当前系统登录用户,可以扩展踢出登录用户的功能。缺点是数据存储量大,且大量token存在缓存中需要进行校验,万一被攻击会导致大量信息泄露。

综上考虑,还是采用黑名单的方式来实现注销登录功能,实时统计在线人数和踢出用户等功能作为扩展功能来开发,不在登录注销逻辑中掺杂太多的业务处理逻辑,使系统保持低耦合。

为了使JWT有效信息最大程度保证准确性,注销登录除了在系统点击退出登录按钮,还需要监测是否直接关闭页面,关闭浏览器事件,来执行调用系统注销接口

token和refresh_token的过期时间不一致,都在其解析之后的exp字段。因为我们定制了黑名单模式,当用户点击退出登录之后,我们会把refresh_token也加入黑名单,在refresh_token获取刷新token的时候,需要定制校验refresh_token是否被加入到黑名单。

1、退出登录接口将token和refresh_token加入黑名单

        /**
* 退出登录需要需要登录的一点思考:
* 1、如果不需要登录,那么在调用接口的时候就需要把token传过来,且系统不校验token有效性,此时如果系统被攻击,不停的大量发送token,最后会把redis充爆
* 2、如果调用退出接口必须登录,那么系统会调用token校验有效性,refresh_token通过参数传过来加入黑名单
* 综上:选择调用退出接口需要登录的方式
* @param request
* @return
*/
@PostMapping("/logout")
public Result logout(HttpServletRequest request) { String token = request.getHeader(AuthConstant.JWT_TOKEN_HEADER);
String refreshToken = request.getParameter(AuthConstant.REFRESH_TOKEN);
long currentTimeSeconds = System.currentTimeMillis() / GitEggConstant.Number.THOUSAND; // 将token和refresh_token同时加入黑名单
String[] tokenArray = new String[GitEggConstant.Number.TWO];
tokenArray[GitEggConstant.Number.ZERO] = token.replace("Bearer ", "");
tokenArray[GitEggConstant.Number.ONE] = refreshToken;
for (int i = GitEggConstant.Number.ZERO; i < tokenArray.length; i++) {
String realToken = tokenArray[i];
JSONObject jsonObject = JwtUtils.decodeJwt(realToken);
String jti = jsonObject.getAsString("jti");
Long exp = Long.parseLong(jsonObject.getAsString("exp"));
if (exp - currentTimeSeconds > GitEggConstant.Number.ZERO) {
redisTemplate.opsForValue().set(AuthConstant.TOKEN_BLACKLIST + jti, jti, (exp - currentTimeSeconds), TimeUnit.SECONDS);
}
}
return Result.success();
}

2、Gateway在AuthorizationManager中添加token是否加入黑名单的判断

        //如果token被加入到黑名单,就是执行了退出登录操作,那么拒绝访问
String realToken = token.replace("Bearer ", "");
try {
JWSObject jwsObject = JWSObject.parse(realToken);
Payload payload = jwsObject.getPayload();
JSONObject jsonObject = payload.toJSONObject();
String jti = jsonObject.getAsString("jti");
String blackListToken = (String)redisTemplate.opsForValue().get(AuthConstant.TOKEN_BLACKLIST + jti);
if (!StringUtils.isEmpty(blackListToken)) {
return Mono.just(new AuthorizationDecision(false));
}
} catch (ParseException e) {
e.printStackTrace();
}

3、自定义DefaultTokenService,校验refresh_token是否被加入黑名单

@Slf4j
public class GitEggTokenServices extends DefaultTokenServices { private final RedisTemplate redisTemplate; public GitEggTokenServices(RedisTemplate redisTemplate)
{
this.redisTemplate = redisTemplate;
} @Transactional(
noRollbackFor = {InvalidTokenException.class, InvalidGrantException.class}
)
@Override
public OAuth2AccessToken refreshAccessToken(String refreshTokenValue, TokenRequest tokenRequest) throws AuthenticationException { JSONObject jsonObject = null;
String jti = null;
//如果refreshToken被加入到黑名单,就是执行了退出登录操作,那么拒绝访问
try {
JWSObject jwsObject = JWSObject.parse(refreshTokenValue);
Payload payload = jwsObject.getPayload();
jsonObject = payload.toJSONObject();
jti = jsonObject.getAsString(TokenConstant.JTI);
String blackListToken = (String)redisTemplate.opsForValue().get(AuthConstant.TOKEN_BLACKLIST + jti);
if (!StringUtils.isEmpty(blackListToken)) {
throw new InvalidTokenException("Invalid refresh token (blackList): " + refreshTokenValue);
}
} catch (ParseException e) {
log.error("获取refreshToken黑名单时发生错误:{}", e);
} OAuth2AccessToken oAuth2AccessToken = super.refreshAccessToken(refreshTokenValue, tokenRequest); // RefreshToken不支持重复使用,如果使用一次,则加入黑名单不再允许使用,当刷新token执行完之后,即校验过RefreshToken之后,才执行存redis操作
if (null != jsonObject && !StringUtils.isEmpty(jti)) {
long currentTimeSeconds = System.currentTimeMillis() / GitEggConstant.Number.THOUSAND;
Long exp = Long.parseLong(jsonObject.getAsString(TokenConstant.EXP));
if (exp - currentTimeSeconds > GitEggConstant.Number.ZERO) {
redisTemplate.opsForValue().set(AuthConstant.TOKEN_BLACKLIST + jti, jti, (exp - currentTimeSeconds), TimeUnit.SECONDS);
}
} return oAuth2AccessToken;
}
}
测试:

1、使用密码模式获取token

Headers里面加TenantId:0参数



2、通过refresh_token刷新token



3、再次执行refresh_token刷新token,此时因为refresh_token已经调用过一次,所以这里不能再次使用

三、前端自动使用refresh_token刷新token

1、使用axios-auth-refresh公共组件,当后台状态返回401时,进行token刷新操作

import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
import store from '@/store'
import storage from 'store'
import { serialize } from '@/utils/util'
import notification from 'ant-design-vue/es/notification'
import modal from 'ant-design-vue/es/modal'
import { VueAxios } from './axios'
import { ACCESS_TOKEN, REFRESH_ACCESS_TOKEN } from '@/store/mutation-types' // 创建 axios 实例
const request = axios.create({
// API 请求的默认前缀
baseURL: process.env.VUE_APP_API_BASE_URL,
timeout: 30000 // 请求超时时间
}) // 当token失效时,需要调用的刷新token的方法
const refreshAuthLogic = failedRequest =>
axios.post(process.env.VUE_APP_API_BASE_URL + '/gitegg-oauth/oauth/token',
serialize({ client_id: process.env.VUE_APP_CLIENT_ID,
client_secret: process.env.VUE_APP_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: storage.get(REFRESH_ACCESS_TOKEN)
}),
{
headers: { 'TenantId': process.env.VUE_APP_TENANT_ID, 'Content-Type': 'application/x-www-form-urlencoded' }
}
).then(tokenRefreshResponse => {
if (tokenRefreshResponse.status === 200 && tokenRefreshResponse.data && tokenRefreshResponse.data.success) {
const result = tokenRefreshResponse.data.data
storage.set(ACCESS_TOKEN, result.tokenHead + result.token, result.expiresIn * 1000)
storage.set(REFRESH_ACCESS_TOKEN, result.refreshToken, result.refreshExpiresIn * 1000)
failedRequest.response.config.headers['Authorization'] = result.tokenHead + result.token
}
return Promise.resolve()
}) // 初始化刷新token拦截器
createAuthRefreshInterceptor(request, refreshAuthLogic, {
pauseInstanceWhileRefreshing: true // 当刷新token执行时,暂停其他请求
}) // 异常拦截处理器
const errorHandler = (error) => {
if (error.response) {
const data = error.response.data
if (error.response.status === 403) {
notification.error({
message: '禁止访问',
description: data.message
})
} else if (error.response.status === 401 && !(data.result && data.result.isLogin)) {
// 当刷新token超时,则调到登录页面
modal.warn({
title: '登录超时',
content: '由于您长时间未操作, 为确保安全, 请重新登录系统进行后续操作 !',
okText: '重新登录',
onOk () {
store.dispatch('Timeout').then(() => {
window.location.reload()
})
}
})
}
}
return Promise.reject(error)
} // request interceptor
request.interceptors.request.use(config => {
const token = storage.get(ACCESS_TOKEN)
// 如果 token 存在
// 让每个请求携带自定义 token 请根据实际情况自行修改
if (token) {
config.headers['Authorization'] = token
}
config.headers['TenantId'] = process.env.VUE_APP_TENANT_ID
return config
}, errorHandler) // response interceptor
request.interceptors.response.use((response) => {
const res = response.data
if (res.code) {
if (res.code !== 200) {
notification.error({
message: '操作失败',
description: res.msg
})
return Promise.reject(new Error(res.msg || 'Error'))
} else {
return response.data
}
} else {
return response
}
}, errorHandler) const installer = {
vm: {},
install (Vue) {
Vue.use(VueAxios, request)
}
} export default request export {
installer as VueAxios,
request as axios
}

四、记住密码功能实现

有时候,在我们在可信任的电脑上可以实现记住密码功能,前后端分离项目的实现只需要把密码记录到localstorage中,然后每次访问登录界面时,自动填入即可。这里先使用明文进行存储,为了系统安全,在实际应用过程需要将密码加密存储,后台校验加密后的密码

1、在created中读取是否记住密码

created () {
this.queryCaptchaType()
this.$nextTick(() => {
const rememberMe = storage.get(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe')
if (rememberMe) {
const username = storage.get(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username')
const password = storage.get(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password')
if (username !== '' && password !== '') {
this.form.setFieldsValue({ 'username': username })
this.form.setFieldsValue({ 'password': password })
this.form.setFieldsValue({ 'rememberMe': true })
}
}
})
},

2、每次登录成功之后,根据是否勾选记住密码来确定是否填入用户名密码

     // 判断是否记住密码
const rememberMe = this.form.getFieldValue('rememberMe')
const username = this.form.getFieldValue('username')
const password = this.form.getFieldValue('password')
if (rememberMe && username !== '' && password !== '') {
storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username', username, 60 * 60 * 24 * 7 * 1000)
storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password', password, 60 * 60 * 24 * 7 * 1000)
storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe', true, 60 * 60 * 24 * 7 * 1000)
} else {
storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username')
storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password')
storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe')
}

五、密码尝试次数过多则锁定账户

从系统安全方面来讲,我们需要支持防止用户账户被暴力破解的措施,目前技术已经能够轻松破解大多数的验证码,这为暴力破解用户账户提供了方便,那么这里我们的系统需要密码尝试次数过多锁定账户的功能。SpringSecurity的UserDetails接口定义了isAccountNonLocked方法来判断账户是否被锁定

public interface UserDetails extends Serializable {
Collection<!--? extends GrantedAuthority--> getAuthorities(); String getPassword(); String getUsername(); boolean isAccountNonExpired(); boolean isAccountNonLocked(); boolean isCredentialsNonExpired(); boolean isEnabled();
}

1、自定义LoginFailureListener事件监听器,监听SpringSecurity抛出AuthenticationFailureBadCredentialsEvent异常事件,使用Redis计数器,记录账号错误密码次数

/**
* 当登录失败时的调用,当密码错误过多时,则锁定账户
* @author GitEgg
* @date 2021-03-12 17:57:05
**/
@Slf4j
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class LoginFailureListener implements ApplicationListener<authenticationfailurebadcredentialsevent> { private final UserDetailsService userDetailsService; private final RedisTemplate redisTemplate; @Value("${system.maxTryTimes}")
private int maxTryTimes; @Override
public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) { if (event.getException().getClass().equals(UsernameNotFoundException.class)) {
return;
} String userName = event.getAuthentication().getName(); GitEggUserDetails user = (GitEggUserDetails) userDetailsService.loadUserByUsername(userName); if (null != user) {
Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + user.getId()).get();
if(null == lockTimes || (int)lockTimes <= maxTryTimes){
redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + user.getId()).increment(GitEggConstant.Number.ONE);
}
}
}
}

2、GitEggUserDetailsServiceImpl方法查询Redis记录的账号锁定次数

            // 判断账号是否被锁定(账户过期,凭证过期等可在此处扩展)
Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();
boolean accountNotLocked = true;
if(null != lockTimes && (int)lockTimes >= maxTryTimes){
accountNotLocked = false;
}

六、登录时是否需要输入验证码

验证码设置前三次(可配置)登录时,不需要输入验证码,当密码尝试次数大于三次时,需要输入验证码,登录方式的一个思路:初始进入登录界面,用户可选择自己的登录方式,我们系统OAuth默认设置了三种登录方式:

  • 用户名+密码登录
  • 用户名+密码+验证码
  • 手机号+验证码登录

系统默认采用用户名+密码登录,当默认的用户名密码登录错误次数(默认一次)超过系统配置的最大次数时,则必须输入验证码登录,当验证码也超过一定次数时(默认五次),都不行则锁定账户二小时之后才可以继续尝试。因为考虑到有些系统可能不会用到短信验证码等,所以这里作为一个扩展功能:如果有需要可以在用户名密码错误过多时,强制只用短信验证码才能登录,且一定要设置超过错误次数就锁定。

1、在自定义的GitEggUserDetailsServiceImpl增加账号判断

            // 从Redis获取账号密码错误次数
Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get(); // 判断账号密码输入错误几次,如果输入错误多次,则锁定账号
// 输入错误大于配置的次数,必须选择captcha或sms_captcha
if (null != lockTimes && (int)lockTimes >= maxNonCaptchaTimes && ( StringUtils.isEmpty(authGrantType) || (!StringUtils.isEmpty(authGrantType)
&& !AuthEnum.SMS_CAPTCHA.code.equals(authGrantType) && !AuthEnum.CAPTCHA.code.equals(authGrantType)))) {
throw new GitEggOAuth2Exception(ResultCodeEnum.INVALID_PASSWORD_CAPTCHA.msg);
} // 判断账号是否被锁定(账户过期,凭证过期等可在此处扩展)
if(null != lockTimes && (int)lockTimes >= maxTryTimes){
throw new LockedException(ResultCodeEnum.PASSWORD_TRY_MAX_ERROR.msg);
} // 判断账号是否被禁用
String userStatus = gitEggUser.getStatus();
if (String.valueOf(GitEggConstant.DISABLE).equals(userStatus)) {
throw new DisabledException(ResultCodeEnum.DISABLED_ACCOUNT.msg);
}

2、自定义OAuth2拦截异常并统一处理

/**
* 自定义Oauth异常拦截处理器
*/
@Slf4j
@RestControllerAdvice
public class GitEggOAuth2ExceptionHandler { @ExceptionHandler(InvalidTokenException.class)
public Result handleInvalidTokenException(InvalidTokenException e) {
return Result.error(ResultCodeEnum.UNAUTHORIZED);
} @ExceptionHandler({UsernameNotFoundException.class})
public Result handleUsernameNotFoundException(UsernameNotFoundException e) {
return Result.error(ResultCodeEnum.INVALID_USERNAME_PASSWORD);
} @ExceptionHandler({InvalidGrantException.class})
public Result handleInvalidGrantException(InvalidGrantException e) {
return Result.error(ResultCodeEnum.INVALID_USERNAME_PASSWORD);
} @ExceptionHandler(InternalAuthenticationServiceException.class)
public Result handleInvalidGrantException(InternalAuthenticationServiceException e) {
Result result = Result.error(ResultCodeEnum.INVALID_USERNAME_PASSWORD);
if (null != e) {
String errorMsg = e.getMessage();
if (ResultCodeEnum.INVALID_PASSWORD_CAPTCHA.getMsg().equals(errorMsg)) {
//必须使用验证码
result = Result.error(ResultCodeEnum.INVALID_PASSWORD_CAPTCHA);
}
else if (ResultCodeEnum.PASSWORD_TRY_MAX_ERROR.getMsg().equals(errorMsg)) {
//账号被锁定
result = Result.error(ResultCodeEnum.PASSWORD_TRY_MAX_ERROR);
}
else if (ResultCodeEnum.DISABLED_ACCOUNT.getMsg().equals(errorMsg)) {
//账号被禁用
result = Result.error(ResultCodeEnum.DISABLED_ACCOUNT);
}
}
return result;
}
}

3、前端登录页面增加判断,默认采用password方式登录,当错误达到一定次数时,必须使用验证码登录

    requestFailed (err) {
this.isLoginError = true
if (err && err.code === 427) {
// 密码错误次数超过最大限值,请选择验证码模式登录
if (this.customActiveKey === 'tab_account') {
this.grantType = 'captcha'
} else {
this.grantType = 'sms_captcha'
}
this.loginErrorMsg = err.msg
if (this.loginCaptchaType === 'sliding') {
this.$refs.verify.show()
}
} else if (err) {
this.loginErrorMsg = err.msg
}
}

备注:

一、当验证报401时:

进行 /auth/token 的post请求时,没有进行http basic认证。

什么是http Basic认证?

http协议的一种认证方式,将客户端id和客户端密码按照“客户端ID:客户端密码”的格式拼接,并用base64编码,放在

header中请求服务端。例子如下:

Authorization:Basic ASDLKFALDSFAJSLDFKLASD=

ASDLKFALDSFAJSLDFKLASD= 就是 客户端ID:客户端密码 的64编码

二、JWT一直不过期:

在自定义TokenEnhancer时,将毫秒加入到了过期时间中,在鉴权解析时,OAuth2是按照秒来解析,所以生成的过期时间非常大,导致token一直未过期。

源码地址:

Gitee: https://gitee.com/wmz1930/GitEgg

GitHub: https://github.com/wmz1930/GitEgg

SpringCloud微服务实战——搭建企业级开发框架(二十三):Gateway+OAuth2+JWT实现微服务统一认证授权的更多相关文章

  1. SpringCloud微服务实战——搭建企业级开发框架(十三):OpenFeign+Ribbon实现高可用重试机制

      Spring Cloud OpenFeign 默认是使用Ribbon实现负载均衡和重试机制的,虽然Feign有自己的重试机制,但该功能在Spring Cloud OpenFeign基本用不上,除非 ...

  2. SpringCloud微服务实战——搭建企业级开发框架(十一):集成OpenFeign用于微服务间调用

    作为Spring Cloud的子项目之一,Spring Cloud OpenFeign以将OpenFeign集成到Spring Boot应用中的方式,为微服务架构下服务之间的调用提供了解决方案.首先, ...

  3. SpringCloud微服务实战——搭建企业级开发框架(二):环境准备

    这里简单说明一下在Windows系统下开发SpringCloud项目所需要的的基本环境,这里只说明开发过程中基础必须的软件,其他扩展功能(Docker,k8s,MinIO,XXL-JOB,EKL,Ke ...

  4. SpringCloud微服务实战——搭建企业级开发框架(四十二):集成分布式任务调度平台XXL-JOB,实现定时任务功能

      定时任务几乎是每个业务系统必不可少的功能,计算到期时间.过期时间等,定时触发某项任务操作.在使用单体应用时,基本使用Spring提供的注解即可实现定时任务,而在使用微服务集群时,这种方式就要考虑添 ...

  5. SpringCloud微服务实战——搭建企业级开发框架(四十五):【微服务监控告警实现方式二】使用Actuator(Micrometer)+Prometheus+Grafana实现完整的微服务监控

      无论是使用SpringBootAdmin还是使用Prometheus+Grafana都离不开SpringBoot提供的核心组件Actuator.提到Actuator,又不得不提Micrometer ...

  6. SpringCloud微服务实战——搭建企业级开发框架(二十七):集成多数据源+Seata分布式事务+读写分离+分库分表

    读写分离:为了确保数据库产品的稳定性,很多数据库拥有双机热备功能.也就是,第一台数据库服务器,是对外提供增删改业务的生产服务器:第二台数据库服务器,主要进行读的操作. 目前有多种方式实现读写分离,一种 ...

  7. SpringCloud微服务实战——搭建企业级开发框架(三十二):代码生成器使用配置说明

    一.新建数据源配置 因考虑到多数据源问题,代码生成器作为一个通用的模块,后续可能会为其他工程生成代码,所以,这里不直接读取系统工程配置的数据源,而是让用户自己维护. 参数说明 数据源名称:用于查找区分 ...

  8. SpringCloud微服务实战——搭建企业级开发框架(三十四):SpringCloud + Docker + k8s实现微服务集群打包部署-Maven打包配置

      SpringCloud微服务包含多个SpringBoot可运行的应用程序,在单应用程序下,版本发布时的打包部署还相对简单,当有多个应用程序的微服务发布部署时,原先的单应用程序部署方式就会显得复杂且 ...

  9. SpringCloud微服务实战——搭建企业级开发框架(三十六):使用Spring Cloud Stream实现可灵活配置消息中间件的功能

      在以往消息队列的使用中,我们通常使用集成消息中间件开源包来实现对应功能,而消息中间件的实现又有多种,比如目前比较主流的ActiveMQ.RocketMQ.RabbitMQ.Kafka,Stream ...

随机推荐

  1. Java语言程序设计与数据结构(基础篇)第七章答案

    答案为本人求解,如有错误,还望海涵.如有雷同,纯属巧合. 7.1 import java.util.Scanner; public class Main { public static void ma ...

  2. Boost Started on Unix Variants

  3. python filter lambda 的使用

    lambda 匿名函数的使用 >>> a=lambda x : x in "1234567890.," >>> a("asd" ...

  4. 初学python-day9 函数1(已更新)

    函数 一.函数基础 1.什么是函数 在一个完整的项目中,某些功能会被重复使用,那么会将代码段封装成函数,当我们要使用的时候,直接调用即可. 函数是可以实现一定的小程序或者功能. 优点: 增加了代码的重 ...

  5. Alpha-功能规格说明书

    项目 内容 这个作业属于哪个课程 2021春季软件工程(罗杰 任健) 这个作业的要求在哪里 团队项目-计划-功能规格说明书 一.引言 1. 项目简介 项目团队:删库跑路对不队 项目名称:题士 项目内容 ...

  6. UltraSoft - Beta - Scrum Meeting 8

    Date: May 24th, 2020. Scrum 情况汇报 进度情况 组员 负责 今日进度 q2l PM.后端 记录Scrum Meeting Liuzh 前端 暂无 Kkkk 前端 暂无 王f ...

  7. ssh后门反向代理实现内网穿透

    如图所示,内网主机ginger 无公网IP地址,防火墙只允许ginger连接blackbox.example.com主机 假如你是ginger的管理员root,你想要用tech主机连接ginger主机 ...

  8. 状压dp学习笔记(紫例题集)

    P3451旅游景点 Tourist Attractions 这个代码其实不算是正规题解的(因为我蒟蒻)是在我们的hzoj上内存限制324MIB情况下过掉的,而且经过研究感觉不太能用滚动数组,所以那这个 ...

  9. OTA测试介绍

    OTA 测试介绍 手机的无源测试和有源测试 当前在手机射频性能测试中越来越关注整机辐射性能的测试,这种辐射性能反映了手目前主要有两种方法对手机的辐射性能进行考察:一种是从天线是目前较为传统的天线测试方 ...

  10. stm32驱动超声波模块

    下面是关于stm32驱动超声波模块的一段代码,有需要的朋友可以复制参考,希望对大家能够有所帮助和启发. #define HCSR04_PORT GPIOB #define HCSR04_CLK RCC ...