??OAuth2是一個關(guān)于授權(quán)的開放標(biāo)準(zhǔn),核心思路是通過各類認(rèn)證手段(具體什么手段OAuth2不關(guān)心)認(rèn)證用戶身份,并頒發(fā)token(令牌),使得第三方應(yīng)用可以使用該token(令牌)在限定時間、限定范圍訪問指定資源。
??OAuth2中使用token驗(yàn)證用戶登錄合法性,但token最大的問題是不攜帶用戶信息,資源服務(wù)器無法在本地進(jìn)行驗(yàn)證,每次對于資源的訪問,資源服務(wù)器都需要向認(rèn)證服務(wù)器發(fā)起請求,一是驗(yàn)證token的有效性,二是獲取token對應(yīng)的用戶信息。如果有大量的此類請求,無疑處理效率是很低,且認(rèn)證服務(wù)器會變成一個中心節(jié)點(diǎn),這在分布式架構(gòu)下很影響性能。如果認(rèn)證服務(wù)器頒發(fā)的是jwt格式的token,那么資源服務(wù)器就可以直接自己驗(yàn)證token的有效性并綁定用戶,這無疑大大提升了處理效率且減少了單點(diǎn)隱患。
??SpringCloud認(rèn)證授權(quán)解決思路:認(rèn)證服務(wù)負(fù)責(zé)認(rèn)證,網(wǎng)關(guān)負(fù)責(zé)校驗(yàn)認(rèn)證和鑒權(quán),其他API服務(wù)負(fù)責(zé)處理自己的業(yè)務(wù)邏輯。安全相關(guān)的邏輯只存在于認(rèn)證服務(wù)和網(wǎng)關(guān)服務(wù)中,其他服務(wù)只是單純地提供服務(wù)而沒有任何安全相關(guān)邏輯。
微服務(wù)鑒權(quán)功能劃分:
- gitegg-oauth:Oauth2用戶認(rèn)證和單點(diǎn)登錄
- gitegg-gateway:請求轉(zhuǎn)發(fā)和統(tǒng)一鑒權(quán)
- gitegg-system: 讀取系統(tǒng)配置的RBAC權(quán)限配置并存放到緩存
一、鑒權(quán)配置
1、GitEgg-Platform工程下新建gitegg-platform-oauth2工程,用于統(tǒng)一管理OAuth2版本,及統(tǒng)一配置
<?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數(shù)據(jù)庫驅(qū)動及連接池 -->
<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>
<!-- 驗(yàn)證碼 -->
<dependency>
<groupId>com.gitegg.platform</groupId>
<artifactId>gitegg-platform-captcha</artifactId>
</dependency>
<!-- gitegg-service-system 的fegin公共調(diào)用方法 -->
<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,復(fù)制到gitegg-oauth工程的resource目錄下,CMD命令行進(jìn)入到JDK安裝目錄的bin目錄下, 使用keytool命令生成gitegg.jks證書
keytool -genkey -alias gitegg -keyalg RSA -keystore gitegg.jks
4、新建GitEggUserDetailsServiceImpl.java實(shí)現(xiàn)SpringSecurity獲取用戶信息接口,用于SpringSecurity鑒權(quán)時獲取用戶信息
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;
/**
* 實(shí)現(xiàn)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) {
// 獲取登錄類型,密碼,二維碼,驗(yàn)證碼
String authLoginType = request.getParameter(AuthConstant.AUTH_TYPE);
// 獲取客戶端id
String clientId = request.getParameter(AuthConstant.AUTH_CLIENT_ID);
// 遠(yuǎn)程調(diào)用返回?cái)?shù)據(jù)
Result<Object> result;
// 通過手機(jī)號碼登錄
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用于認(rèn)證服務(wù)相關(guān)配置,正式環(huán)境請一定記得修改gitegg.jks配置的密碼,這里默認(rèn)為123456。TokenEnhancer 為登錄用戶的擴(kuò)展信息,可以自己定義。
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;
/**
* 認(rèn)證服務(wù)配置
*/
@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);
}
/**
* 配置授權(quán)(authorization)以及令牌(token)的訪問端點(diǎn)和令牌服務(wù)(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有兩種使用方式:重復(fù)使用(true)、非重復(fù)使用(false),默認(rèn)為true
* 1.重復(fù)使用:access_token過期刷新時, refresh token過期時間未改變,仍以初次生成的時間為準(zhǔn)
* 2.非重復(fù)使用:access_token過期刷新時, refresh_token過期時間延續(xù),在refresh_token有效期內(nèi)刷新而無需失效再次登錄
*/
.reuseRefreshTokens(false);
}
/**
* 允許表單認(rèn)證
*/
@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內(nèi)容增強(qiáng)
*/
@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());
//不把權(quán)限菜單放到j(luò)wt里面,當(dāng)菜單太多時,會導(dǎo)致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在認(rèn)證授權(quán)時需要RSA的公鑰來驗(yàn)證簽名是否合法,所以這里新建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資源服務(wù)器配置,放開public_key的讀取權(quán)限
@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實(shí)現(xiàn)CommandLineRunner接口,用于系統(tǒng)啟動時加載RBAC權(quán)限配置信息到緩存
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;
/**
* 容器啟動完成加載資源權(quán)限數(shù)據(jù)到緩存
*/
@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");
// 查詢系統(tǒng)角色和權(quán)限的關(guān)系
List<Resource> resourceList = resourceService.queryResourceRoleIds();
// 判斷是否開啟了租戶模式,如果開啟了,那么角色權(quán)限需要按租戶進(jìn)行分類存儲
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、新建網(wǎng)關(guān)服務(wù)gitegg-gateway,作為Oauth2的資源服務(wù)、客戶端服務(wù)使用,對訪問微服務(wù)的請求進(jìn)行轉(zhuǎn)發(fā)、統(tǒng)一校驗(yàn)認(rèn)證和鑒權(quán)操作,引入相關(guān)依賴
<?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 服務(wù)注冊發(fā)現(xiàn) -->
<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 微服務(wù)調(diào)用解決方案 -->
<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自定義擴(kuò)展 -->
<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網(wǎng)關(guān)服務(wù)進(jìn)行配置安全配置,需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因?yàn)镾pringCloud 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;
/**
* 資源服務(wù)器配置
*/
@AllArgsConstructor
@Configuration
// 注解需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因?yàn)镾pringCloud 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請求頭過期或簽名錯誤的結(jié)果
http.oauth2ResourceServer().authenticationEntryPoint(authServerAuthenticationEntryPoint);
// 對白名單路徑,直接移除JWT請求頭,不移除的話,后臺會校驗(yàn)jwt
http.addFilterBefore(whiteListRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
http.authorizeExchange()
.pathMatchers(ArrayUtil.toArray(authUrlWhiteListProperties.getUrls(), String.class)).permitAll()
.anyExchange().access(authorizationManager)
.and()
.exceptionHandling()
.accessDeniedHandler(authServerAccessDeniedHandler) // 處理未授權(quán)
.authenticationEntryPoint(authServerAuthenticationEntryPoint) //處理未認(rèn)證
.and()
.cors()
.and().csrf().disable();
return http.build();
}
/**
* ServerHttpSecurity沒有將jwt中authorities的負(fù)載部分當(dāng)做Authentication,需要把jwt的Claim中的authorities加入
* 解決方案:重新定義ReactiveAuthenticationManager權(quán)限管理器,默認(rèn)轉(zhuǎn)換器JwtGrantedAuthoritiesConverter
*/
@Bean
public Converter<Jwt, ? extends Mono<? extends 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實(shí)現(xiàn)ReactiveAuthorizationManager接口,用于自定義權(quán)限校驗(yàn)
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;
/**
* 網(wǎng)關(guān)鑒權(quán)管理器
*/
@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();
// 對應(yīng)跨域的預(yù)檢請求直接放行
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;
// 判斷是否開啟了租戶模式,如果開啟了,那么按租戶分類的方式獲取角色權(quán)限
if (enable) {
redisRoleKey += tenantId;
} else {
redisRoleKey = AuthConstant.RESOURCE_ROLES_KEY;
}
// 緩存取資源權(quán)限角色關(guān)系列表
Map<Object, Object> resourceRolesMap = redisTemplate.opsForHash().entries(redisRoleKey);
Iterator<Object> iterator = resourceRolesMap.keySet().iterator();
//請求路徑匹配到的資源需要的角色權(quán)限集合authorities統(tǒng)計(jì)
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("資源需要權(quán)限authorities:{}", authorities);
return authorities.contains(roleId);
})
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
return authorizationDecisionMono;
}
}
12、新建AuthGlobalFilter.java全局過濾器,解析用戶請求信息,將用戶信息及租戶信息放在請求的Header中,這樣后續(xù)服務(wù)就不需要解析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轉(zhuǎn)化成用戶信息的全局過濾器
*/
@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<>();
// 如果系統(tǒng)配置已開啟租戶模式,設(shè)置tenantId
if (enable && StrUtil.isEmpty(tenantId)) {
addHeaders.put(AuthConstant.TENANT_ID, tenantId);
}
if (!StrUtil.isEmpty(token)) {
try {
//從token中解析用戶信息并設(shè)置到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中添加權(quán)限相關(guān)配置信息:
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
# 網(wǎng)關(guān)放行白名單,配置白名單路徑
white-list:
urls:
- "/gitegg-oauth/oauth/public_key"
二、注銷登錄使JWT失效
??因?yàn)镴WT是無狀態(tài)的且不在服務(wù)端儲存,所以,當(dāng)系統(tǒng)在執(zhí)行退出登錄時就無法使JWT失效,我們有兩種方式拒絕注銷登錄后的JWT:
JWT白名單:每次登錄成功就將JWT存到緩存中,緩存有效期和JWT有效期保持一致,注銷登錄就將JWT從緩存中移出。Gateway每次認(rèn)證授權(quán)先從緩存JWT白名單中獲取是否存在該JWT,存在則繼續(xù)校驗(yàn),不存在則拒絕訪問。
JWT黑名單:每當(dāng)注銷登錄時,將JWT存到緩存中,解析JWT的到期時間,將緩存過期時間設(shè)置為和JWT一致。Gateway每次認(rèn)證授權(quán)先從緩存中獲取JWT是否存在于黑名單中,存在則拒絕訪問,不存在則繼續(xù)校驗(yàn)。
??不管是白名單還是黑名單,實(shí)現(xiàn)方式的原理都基本一致,就是將JWT先存放到緩存,再根據(jù)不同的狀態(tài)進(jìn)行判斷JWT是否有效,下面是兩種方式的優(yōu)缺點(diǎn)分析:
- 黑名單功能分析:優(yōu)點(diǎn)是存放到緩存的數(shù)據(jù)量將小于白名單方式存放的數(shù)據(jù)量,缺點(diǎn)是無法獲知當(dāng)前簽發(fā)了多少JWT,當(dāng)前在線多少登錄用戶。
- 白名單功能分析:優(yōu)點(diǎn)是當(dāng)我們需要統(tǒng)計(jì)在線用戶的時候,白名單方式可以近似的獲取到當(dāng)前系統(tǒng)登錄用戶,可以擴(kuò)展踢出登錄用戶的功能。缺點(diǎn)是數(shù)據(jù)存儲量大,且大量token存在緩存中需要進(jìn)行校驗(yàn),萬一被攻擊會導(dǎo)致大量信息泄露。
綜上考慮,還是采用黑名單的方式來實(shí)現(xiàn)注銷登錄功能,實(shí)時統(tǒng)計(jì)在線人數(shù)和踢出用戶等功能作為擴(kuò)展功能來開發(fā),不在登錄注銷邏輯中摻雜太多的業(yè)務(wù)處理邏輯,使系統(tǒng)保持低耦合。
為了使JWT有效信息最大程度保證準(zhǔn)確性,注銷登錄除了在系統(tǒng)點(diǎn)擊退出登錄按鈕,還需要監(jiān)測是否直接關(guān)閉頁面,關(guān)閉瀏覽器事件,來執(zhí)行調(diào)用系統(tǒng)注銷接口。
token和refresh_token的過期時間不一致,都在其解析之后的exp字段。因?yàn)槲覀兌ㄖ屏撕诿麊文J?,?dāng)用戶點(diǎn)擊退出登錄之后,我們會把refresh_token也加入黑名單,在refresh_token獲取刷新token的時候,需要定制校驗(yàn)refresh_token是否被加入到黑名單。
1、退出登錄接口將token和refresh_token加入黑名單
/**
* 退出登錄需要需要登錄的一點(diǎn)思考:
* 1、如果不需要登錄,那么在調(diào)用接口的時候就需要把token傳過來,且系統(tǒng)不校驗(yàn)token有效性,此時如果系統(tǒng)被攻擊,不停的大量發(fā)送token,最后會把redis充爆
* 2、如果調(diào)用退出接口必須登錄,那么系統(tǒng)會調(diào)用token校驗(yàn)有效性,refresh_token通過參數(shù)傳過來加入黑名單
* 綜上:選擇調(diào)用退出接口需要登錄的方式
* @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被加入到黑名單,就是執(zhí)行了退出登錄操作,那么拒絕訪問
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,校驗(yàn)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被加入到黑名單,就是執(zhí)行了退出登錄操作,那么拒絕訪問
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黑名單時發(fā)生錯誤:{}", e);
}
OAuth2AccessToken oAuth2AccessToken = super.refreshAccessToken(refreshTokenValue, tokenRequest);
// RefreshToken不支持重復(fù)使用,如果使用一次,則加入黑名單不再允許使用,當(dāng)刷新token執(zhí)行完之后,即校驗(yàn)過RefreshToken之后,才執(zhí)行存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參數(shù)

2、通過refresh_token刷新token

3、再次執(zhí)行refresh_token刷新token,此時因?yàn)閞efresh_token已經(jīng)調(diào)用過一次,所以這里不能再次使用

三、前端自動使用refresh_token刷新token
1、使用axios-auth-refresh公共組件,當(dāng)后臺狀態(tài)返回401時,進(jìn)行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'
// 創(chuàng)建 axios 實(shí)例
const request = axios.create({
// API 請求的默認(rèn)前綴
baseURL: process.env.VUE_APP_API_BASE_URL,
timeout: 30000 // 請求超時時間
})
// 當(dāng)token失效時,需要調(diào)用的刷新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 // 當(dāng)刷新token執(zhí)行時,暫停其他請求
})
// 異常攔截處理器
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)) {
// 當(dāng)刷新token超時,則調(diào)到登錄頁面
modal.warn({
title: '登錄超時',
content: '由于您長時間未操作, 為確保安全, 請重新登錄系統(tǒng)進(jìn)行后續(xù)操作 !',
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 請根據(jù)實(shí)際情況自行修改
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
}
四、記住密碼功能實(shí)現(xiàn)
有時候,在我們在可信任的電腦上可以實(shí)現(xiàn)記住密碼功能,前后端分離項(xiàng)目的實(shí)現(xiàn)只需要把密碼記錄到localstorage中,然后每次訪問登錄界面時,自動填入即可。這里先使用明文進(jìn)行存儲,為了系統(tǒng)安全,在實(shí)際應(yīng)用過程需要將密碼加密存儲,后臺校驗(yàn)加密后的密碼
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、每次登錄成功之后,根據(jù)是否勾選記住密碼來確定是否填入用戶名密碼
// 判斷是否記住密碼
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')
}
五、密碼嘗試次數(shù)過多則鎖定賬戶
從系統(tǒng)安全方面來講,我們需要支持防止用戶賬戶被暴力破解的措施,目前技術(shù)已經(jīng)能夠輕松破解大多數(shù)的驗(yàn)證碼,這為暴力破解用戶賬戶提供了方便,那么這里我們的系統(tǒng)需要密碼嘗試次數(shù)過多鎖定賬戶的功能。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事件監(jiān)聽器,監(jiān)聽SpringSecurity拋出AuthenticationFailureBadCredentialsEvent異常事件,使用Redis計(jì)數(shù)器,記錄賬號錯誤密碼次數(shù)
/**
* 當(dāng)?shù)卿浭r的調(diào)用,當(dāng)密碼錯誤過多時,則鎖定賬戶
* @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記錄的賬號鎖定次數(shù)
// 判斷賬號是否被鎖定(賬戶過期,憑證過期等可在此處擴(kuò)展)
Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();
boolean accountNotLocked = true;
if(null != lockTimes && (int)lockTimes >= maxTryTimes){
accountNotLocked = false;
}
六、登錄時是否需要輸入驗(yàn)證碼
驗(yàn)證碼設(shè)置前三次(可配置)登錄時,不需要輸入驗(yàn)證碼,當(dāng)密碼嘗試次數(shù)大于三次時,需要輸入驗(yàn)證碼,登錄方式的一個思路:初始進(jìn)入登錄界面,用戶可選擇自己的登錄方式,我們系統(tǒng)OAuth默認(rèn)設(shè)置了三種登錄方式:
- 用戶名+密碼登錄
- 用戶名+密碼+驗(yàn)證碼
- 手機(jī)號+驗(yàn)證碼登錄
系統(tǒng)默認(rèn)采用用戶名+密碼登錄,當(dāng)默認(rèn)的用戶名密碼登錄錯誤次數(shù)(默認(rèn)一次)超過系統(tǒng)配置的最大次數(shù)時,則必須輸入驗(yàn)證碼登錄,當(dāng)驗(yàn)證碼也超過一定次數(shù)時(默認(rèn)五次),都不行則鎖定賬戶二小時之后才可以繼續(xù)嘗試。因?yàn)榭紤]到有些系統(tǒng)可能不會用到短信驗(yàn)證碼等,所以這里作為一個擴(kuò)展功能:如果有需要可以在用戶名密碼錯誤過多時,強(qiáng)制只用短信驗(yàn)證碼才能登錄,且一定要設(shè)置超過錯誤次數(shù)就鎖定。
1、在自定義的GitEggUserDetailsServiceImpl增加賬號判斷
// 從Redis獲取賬號密碼錯誤次數(shù)
Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();
// 判斷賬號密碼輸入錯誤幾次,如果輸入錯誤多次,則鎖定賬號
// 輸入錯誤大于配置的次數(shù),必須選擇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);
}
// 判斷賬號是否被鎖定(賬戶過期,憑證過期等可在此處擴(kuò)展)
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攔截異常并統(tǒng)一處理
/**
* 自定義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)) {
//必須使用驗(yàn)證碼
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、前端登錄頁面增加判斷,默認(rèn)采用password方式登錄,當(dāng)錯誤達(dá)到一定次數(shù)時,必須使用驗(yàn)證碼登錄
requestFailed (err) {
this.isLoginError = true
if (err && err.code === 427) {
// 密碼錯誤次數(shù)超過最大限值,請選擇驗(yàn)證碼模式登錄
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
}
}
備注:
一、當(dāng)驗(yàn)證報(bào)401時:
進(jìn)行 /auth/token 的post請求時,沒有進(jìn)行http basic認(rèn)證。
什么是http Basic認(rèn)證?
http協(xié)議的一種認(rèn)證方式,將客戶端id和客戶端密碼按照“客戶端ID:客戶端密碼”的格式拼接,并用base64編碼,放在
header中請求服務(wù)端。例子如下:
Authorization:Basic ASDLKFALDSFAJSLDFKLASD=
ASDLKFALDSFAJSLDFKLASD= 就是 客戶端ID:客戶端密碼 的64編碼
二、JWT一直不過期:
在自定義TokenEnhancer時,將毫秒加入到了過期時間中,在鑒權(quán)解析時,OAuth2是按照秒來解析,所以生成的過期時間非常大,導(dǎo)致token一直未過期。
GitEgg-Cloud是一款基于SpringCloud整合搭建的企業(yè)級微服務(wù)應(yīng)用開發(fā)框架,開源項(xiàng)目地址:
Gitee: https://gitee.com/wmz1930/GitEgg
GitHub: https://github.com/wmz1930/GitEgg
歡迎感興趣的小伙伴Star支持一下。