OAuth2是一个关于授权的开放标准,核心思路是通过各类认证手段(具体什么手段OAuth2不关心)认证用户身份,并颁发token(令牌),使得第三方应用可以使用该token(令牌)在限定时间、限定范围访问指定资源。
OAuth2中使用token验证用户登录合法性,但token最大的问题是不携带用户信息,资源服务器无法在本地进行验证,每次对于资源的访问,资源服务器都需要向认证服务器发起请求,一是验证token的有效性,二是获取token对应的用户信息。如果有大量的此类请求,无疑处理效率是很低,且认证服务器会变成一个中心节点,这在分布式架构下很影响性能。如果认证服务器颁发的是jwt格式的token,那么资源服务器就可以直接自己验证token的有效性并绑定用户,这无疑大大提升了处理效率且减少了单点隐患。
SpringCloud认证授权解决思路:认证服务负责认证,网关负责校验认证和鉴权,其他API服务负责处理自己的业务逻辑。安全相关的逻辑只存在于认证服务和网关服务中,其他服务只是单纯地提供服务而没有任何安全相关逻辑。
微服务鉴权功能划分:
1、GitEgg-Platform工程下新建gitegg-platform-oauth2工程,用于统一管理OAuth2版本,及统一配置
GitEgg-Platform
com.gitegg.platform
1.0-SNAPSHOT
4.0.0
gitegg-platform-oauth2
${project.artifactId}
jar
org.springframework.boot
spring-boot-configuration-processor
org.springframework.cloud
spring-cloud-starter-oauth2
org.springframework.security
spring-security-oauth2-jose
org.springframework.security
spring-security-oauth2-resource-server
com.gitegg.platform
gitegg-platform-swagger
true
2、在gitegg-oauth工程中引入需要的库
GitEgg-Cloud
com.gitegg.cloud
1.0-SNAPSHOT
4.0.0
gitegg-oauth
${project.artifactId}
jar
com.gitegg.platform
gitegg-platform-boot
${gitegg.project.version}
com.gitegg.platform
gitegg-platform-cloud
${gitegg.project.version}
com.gitegg.platform
gitegg-platform-oauth2
${gitegg.project.version}
com.gitegg.platform
gitegg-platform-db
com.gitegg.platform
gitegg-platform-mybatis
com.gitegg.platform
gitegg-platform-captcha
com.gitegg.cloud
gitegg-service-system-api
${gitegg.project.version}
org.apache.tomcat.embed
tomcat-embed-core
org.springframework.boot
spring-boot-starter-data-redis
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
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 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 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 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 resourceList = resourceService.queryResourceRoleIds();
// 判断是否开启了租户模式,如果开启了,那么角色权限需要按租户进行分类存储
if (enable) {
Map> 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 resourceList) {
Map> resourceRolesMap = new TreeMap<>();
Optional.ofNullable(resourceList).orElse(new ArrayList<>()).forEach(resource -> {
// roleId -> ROLE_{roleId}
List 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的资源服务、客户端服务使用,对访问微服务的请求进行转发、统一校验认证和鉴权操作,引入相关依赖
GitEgg-Cloud
com.gitegg.cloud
1.0-SNAPSHOT
4.0.0
gitegg-gateway
com.gitegg.platform
gitegg-platform-base
${gitegg.project.version}
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-config
org.springframework.cloud
spring-cloud-starter-openfeign
com.gitegg.platform
gitegg-platform-oauth2
${gitegg.project.version}
com.gitegg.platform
gitegg-platform-cache
${gitegg.project.version}
org.springframework.cloud
spring-cloud-starter-gateway
io.springfox
springfox-swagger2
com.github.xiaoymin
knife4j-spring-ui
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> 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 {
private final RedisTemplate redisTemplate;
/**
* 是否开启租户模式
*/
@Value(("${tenant.enable}"))
private Boolean enable;
@Override
public Mono check(Mono 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
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 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 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 = 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从缓存中移出。Gateway每次认证授权先从缓存JWT白名单中获取是否存在该JWT,存在则继续校验,不存在则拒绝访问。
JWT黑名单:每当注销登录时,将JWT存到缓存中,解析JWT的到期时间,将缓存过期时间设置为和JWT一致。Gateway每次认证授权先从缓存中获取JWT是否存在于黑名单中,存在则拒绝访问,不存在则继续校验。
不管是白名单还是黑名单,实现方式的原理都基本一致,就是将JWT先存放到缓存,再根据不同的状态进行判断JWT是否有效,下面是两种方式的优缺点分析:
综上考虑,还是采用黑名单的方式来实现注销登录功能,实时统计在线人数和踢出用户等功能作为扩展功能来开发,不在登录注销逻辑中掺杂太多的业务处理逻辑,使系统保持低耦合。
为了使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已经调用过一次,所以这里不能再次使用
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 {
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
欢迎感兴趣的小伙伴Star支持一下。