实现思路受到开源电商项目mall
和youlai-mall
启发,此处贴上他们的开源地址
mall
: https://gitee.com/macrozheng/mall
youlai-mall
: https://gitee.com/youlaitech/youlai-mall
该篇内容主要为了提升自己对oauth2技术的理解、记录走过的坑的一些解决方案以及对网上零散实现方式的整合
用户登录,网关远程调用认证授权服务完成登录,办法token,使用jwt,本文仅为password认证模式,其他模式请了解Oauth的授权模式后自行百度,大同小异
当网关收到客户端请求的时候,验证用户Token是否正确,正确则校验用户是否具备当前请求路径的权限
内部服务之间裸奔,不校验权限
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-oauth2artifactId>
dependency>
<dependency>
<groupId>org.springframework.securitygroupId>
<artifactId>spring-security-oauth2-joseartifactId>
dependency>
package top.sclf.auth.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
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.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 top.sclf.auth.domain.CustomUserDetails;
import top.sclf.auth.exception.CustomWebResponseExceptionTranslator;
import top.sclf.common.core.constant.AuthConstants;
import java.security.KeyPair;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author zhangxing
* @date 2021/4/4
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
public AuthorizationServerConfig(
AuthenticationManager authenticationManager,
@Qualifier("userDetailsServiceImpl") UserDetailsService userDetailsService
) {
this.authenticationManager = authenticationManager;
this.userDetailsService = userDetailsService;
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 此处客户端可以写死到内存中,也可以从数据库读取,视具体业务而变,客户端作用此处不在讲述
clients.inMemory()
// 客户端id
.withClient("client")
// 客户端密码
.secret("123456")
// 自动授权配置
.autoApprove(true)
.scopes("all")
// 客户端授权类型(authorization_code:授权码类型 password:密码类型 implicit:简化类型/隐式类型 client_credentials:客户端类型 refresh_token:该为特例,加了才可以刷新授权)
.authorizedGrantTypes("password", "refresh_token")
.accessTokenValiditySeconds(3600 * 24)
.refreshTokenValiditySeconds(3600 * 24 * 7);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> tokenEnhancers = new ArrayList<>();
// 增强jwt载荷的内容
tokenEnhancers.add(tokenEnhancer());
// 添加jwt的加密公钥
tokenEnhancers.add(jwtAccessTokenConverter());
tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);
endpoints.authenticationManager(authenticationManager)
.accessTokenConverter(jwtAccessTokenConverter())
.tokenEnhancer(tokenEnhancerChain)
// 使用自己实现的用户密码校验逻辑
.userDetailsService(userDetailsService)
// 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();
}
/**
* jwt生成使用RS256非对称加密,非对称加密需要私钥和公钥,此处设置公钥
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(keyPair());
return converter;
}
/**
* 从classpath下的密钥库中获取密钥对(公钥+私钥)
*/
@Bean
public KeyPair keyPair() {
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(
new ClassPathResource("jcps.jks"), "123456".toCharArray());
return factory.getKeyPair(
"jcps", "123456".toCharArray());
}
/**
* JWT内容增强
* 在jwt的载荷中加入自定义的一些内容
*/
@Bean
public TokenEnhancer tokenEnhancer() {
return (accessToken, authentication) -> {
Map<String, Object> map = new HashMap<>(1);
CustomUserDetails user = (CustomUserDetails) authentication.getUserAuthentication().getPrincipal();
map.put(AuthConstants.DETAILS_USER_ID, user.getId());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
return accessToken;
};
}
}
PS:jks加密可百度搜索如何生成
以上为认证服务的核心配置
配置好后会自动生成一下几个请求端点
/oauth/authorize
method=[POST] 授权码类型和隐式类型的授权端点/oauth/token
method=[GET,POST] 获取令牌的端点,password模式或有授权code情况下用于获取token/oauth/check_token
请求方式没有测试过,用于检查令牌有效性由于使用Spring Security
,故需要开放以上的端点提供给Gateway调用,所以需要添加WebSecurity相关配置
package top.sclf.auth.config;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* SpringSecurity配置
*
* @author zhangxing
*/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
// 使用jwt,则无需使用原本的session管理
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.and()
.authorizeRequests()
// actuator中的所有健康检查端点都放行,经测试,此处包含了上述几处oauth端点,直接放行
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
// 此处不加项目的context-path
.antMatchers("/oauth/logout").permitAll()
.antMatchers("/getPublicKey").permitAll()
.anyRequest().authenticated();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
// 上面的认证服务核心配置需要使用AuthenticationManager来配置UserDetailsService
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
// 测试方便使用密码不加密模式
return NoOpPasswordEncoder.getInstance();
// return new BCryptPasswordEncoder();
}
}
关于/getPublicKey
端点说明:因为jwt使用RS256非对称加密,非对称加密使用相同的公钥和不同的密钥加密而成,所以在认证服务模块中开放公钥的获取方式
添加对外暴露的退出登录接口和开放公钥接口
package top.sclf.auth.controller;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.security.KeyPair;
import java.security.interfaces.RSAPublicKey;
import java.util.Map;
/**
* RSA公钥开放接口
*
* @author zhangxing
* @date 2021/4/5
*/
@RestController
public class PublicKeyController {
private final KeyPair keyPair;
public PublicKeyController(KeyPair keyPair) {
this.keyPair = keyPair;
}
@GetMapping("/getPublicKey")
public Map<String, Object> getPublicKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
关于/oauth/logout
退出登录的端点的说明
因为JWT本身就是字包含的加密文本,所以不需要在服务端存储Token的过期时间,JWT本身就可以验证自己是否正确,以及什么时候过期,所以意味着Token一旦颁发,从Token本身来说必须等Token本身过期才会失效,为了防止用户退出登录后,Token依旧有效,我们可以在用户退出或者修改密码后将Token加入到Redis中,并设置过期时间,可以理解为将Token加入黑名单,再在gateway上添加过滤器识别token是否在黑名单中即可实现用户的退出改密作废Token的功能
package top.sclf.auth.controller;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import top.sclf.common.core.constant.AuthConstants;
import top.sclf.common.core.http.ResultEntity;
import javax.servlet.http.HttpServletRequest;
import java.security.Principal;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 自定义Oauth2获取令牌接口
*
* @author zhangxing
*/
@RestController
@RequestMapping("/oauth")
public class AuthController {
@Autowired
private TokenEndpoint tokenEndpoint;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@PostMapping("/token")
public ResultEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody();
return ResultEntity.ok(oAuth2AccessToken);
}
@DeleteMapping("/logout")
public ResultEntity<?> logout(HttpServletRequest request) {
String payload = request.getHeader(AuthConstants.USER_TOKEN_HEADER);
JSONObject jsonObject = JSONUtil.parseObj(payload);
// JWT唯一标识
String jti = jsonObject.getStr("jti");
// JWT过期时间戳(单位:秒)
long exp = jsonObject.getLong("exp");
long currentTimeSeconds = System.currentTimeMillis() / 1000;
// token已过期
if (exp < currentTimeSeconds) {
return ResultEntity.fail("登录凭证超时");
}
redisTemplate.opsForValue().set(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti, null, (exp - currentTimeSeconds), TimeUnit.SECONDS);
return ResultEntity.ok();
}
}
为什么这里又重写了获取/oauth/token
端点呢,是为了通过@RestControllerAdvice
来捕捉异常
添加异常捕获
/**
* @author zhangxing
*/
@ControllerAdvice
public class Oauth2ExceptionHandler {
@ResponseBody
@ExceptionHandler(value = OAuth2Exception.class)
public ResultEntity<?> handleOauth2(OAuth2Exception e) {
return ResultEntity.fail(e.getMessage());
}
}
添加认证中查询用户的实现
package top.sclf.auth.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import top.sclf.api.resource.RemoteResUserService;
import top.sclf.api.resource.domain.model.LoginUser;
import top.sclf.auth.constant.MessageConstant;
import top.sclf.auth.domain.CustomUserDetails;
import top.sclf.common.core.enums.DelFlagEnum;
import top.sclf.common.core.http.ResultEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* @author zhangxing
* @date 2021/4/4
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
// 远程调用用户服务
@Autowired
private RemoteResUserService remoteResUserService;
@Override
public UserDetails loadUserByUsername(String loginName) throws UsernameNotFoundException {
ResultEntity<LoginUser> loginUserRes = remoteResUserService.getByLoginName(loginName);
LoginUser.User user = Optional.ofNullable(loginUserRes)
.map(ResultEntity::getData)
.map(LoginUser::getResUser)
.orElse(null);
if (user == null) {
throw new UsernameNotFoundException(MessageConstant.USERNAME_PASSWORD_ERROR);
}
CustomUserDetails userDetails = new CustomUserDetails();
userDetails.setId(user.getId());
userDetails.setLoginName(user.getLoginName());
userDetails.setUserName(user.getUserName());
userDetails.setPassword(user.getLoginPwd());
userDetails.setEnable(Objects.equals(user.getDelFlag(), DelFlagEnum.DEFAULT.getVal()));
// 此处查询系统中用户的角色权限等信息
List<CustomUserDetails.Perm> perms = new ArrayList<CustomUserDetails.Perm>(){{
add(new CustomUserDetails.Perm("/a"));
add(new CustomUserDetails.Perm("/b"));
}};
userDetails.setPermList(perms);
return userDetails;
}
}
package top.sclf.auth.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
/**
* @author zhangxing
* @date 2021/4/4
*/
@Data
public class CustomUserDetails implements UserDetails, Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String loginName;
private String userName;
@JsonIgnore
private String password;
private boolean enable;
private List<Perm> permList;
@Data
@AllArgsConstructor
public static class Perm implements GrantedAuthority {
private String uri;
@Override
public String getAuthority() {
return uri;
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return permList;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.loginName;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enable;
}
}
<dependency>
<groupId>org.springframework.securitygroupId>
<artifactId>spring-security-configartifactId>
dependency>
<dependency>
<groupId>org.springframework.securitygroupId>
<artifactId>spring-security-oauth2-resource-serverartifactId>
dependency>
<dependency>
<groupId>org.springframework.securitygroupId>
<artifactId>spring-security-oauth2-joseartifactId>
dependency>
package top.sclf.gateway.config;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
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 reactor.core.publisher.Mono;
import top.sclf.common.core.constant.AuthConstants;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
/**
* 鉴权管理器
*
* @author zhangxing
*/
@Slf4j
@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private final RedisTemplate redisTemplate;
public AuthorizationManager(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
ServerHttpRequest request = authorizationContext.getExchange().getRequest();
String path = request.getURI().getPath();
PathMatcher pathMatcher = new AntPathMatcher();
// 1. 对应跨域的预检请求直接放行
if (request.getMethod() == HttpMethod.OPTIONS) {
return Mono.just(new AuthorizationDecision(true));
}
// 2. token为空拒绝访问
String token = request.getHeaders().getFirst(AuthConstants.HEADER);
if (StrUtil.isBlank(token)) {
return Mono.just(new AuthorizationDecision(false));
}
// 3.缓存取资源权限角色关系列表
// Map
Map<Object, Object> resourceRolesMap = new HashMap<>(0);
Iterator<Object> iterator = resourceRolesMap.keySet().iterator();
// 4.请求路径匹配到的资源需要的角色权限集合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)));
}
}
return mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(roleId -> {
// 5. roleId是请求用户的角色(格式:ROLE_{roleId}),authorities是请求资源所需要角色的集合
log.info("访问路径:{}", path);
log.info("用户角色roleId:{}", roleId);
log.info("资源需要权限authorities:{}", authorities);
// return authorities.contains(roleId);
return true;
})
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
}
以上的鉴权逻辑参考的mall
项目,可以根据自己的业务修改
资源服务需要申明哪些资源需要被保护起来,哪些资源放行,以及被保护起来的资源的保护逻辑(鉴权管理器)
package top.sclf.gateway.config;
import cn.hutool.core.util.ArrayUtil;
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 reactor.core.publisher.Mono;
import top.sclf.gateway.filter.BlackListFilter;
import top.sclf.gateway.handler.CustomServerAccessDeniedHandler;
import top.sclf.gateway.handler.CustomServerAuthenticationEntryPoint;
import top.sclf.gateway.properties.IgnoreWhiteProperties;
/**
* 资源服务器配置
*
* @author zhangxing
*/
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {
private final AuthorizationManager authorizationManager;
private final CustomServerAccessDeniedHandler customServerAccessDeniedHandler;
private final CustomServerAuthenticationEntryPoint customServerAuthenticationEntryPoint;
private final IgnoreWhiteProperties ignoreWhiteProperties;
private final BlackListFilter blackListFilter;
public ResourceServerConfig(AuthorizationManager authorizationManager, CustomServerAccessDeniedHandler customServerAccessDeniedHandler, CustomServerAuthenticationEntryPoint customServerAuthenticationEntryPoint, IgnoreWhiteProperties ignoreWhiteProperties, BlackListFilter blackListFilter) {
this.authorizationManager = authorizationManager;
this.customServerAccessDeniedHandler = customServerAccessDeniedHandler;
this.customServerAuthenticationEntryPoint = customServerAuthenticationEntryPoint;
this.ignoreWhiteProperties = ignoreWhiteProperties;
this.blackListFilter = blackListFilter;
}
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http.oauth2ResourceServer().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
// 自定义处理JWT请求头过期或签名错误的结果
http.oauth2ResourceServer().authenticationEntryPoint(customServerAuthenticationEntryPoint);
// 在鉴权之前,添加一个黑名单过滤器,即认证服务中说到的用户退出或修改密码后,应该将Token加入黑名单,如果Token已经在黑名单中了,则由该Token发起的请求也无需再做鉴权判断了,所以黑名单过滤器必须在鉴权之前,所以放在了认证过滤器的前面
http.addFilterBefore(blackListFilter, SecurityWebFiltersOrder.AUTHENTICATION);
http.authorizeExchange()
// 在网关上放行的请求,该白名单为List,可自行配置,必须包含如下两个请求
// /oauth/login,/getPublicKey
.pathMatchers(ArrayUtil.toArray(ignoreWhiteProperties.getWhites(), String.class)).permitAll()
// 认证通过后即可发起退出登录请求
.pathMatchers("/auth/oauth/logout").authenticated()
// 鉴权管理器,剩下的请求通过鉴权管理器判定
.anyExchange().access(authorizationManager)
.and()
// 添加异常处理的响应
.exceptionHandling()
// 处理未授权
.accessDeniedHandler(customServerAccessDeniedHandler)
// 处理未认证
.authenticationEntryPoint(customServerAuthenticationEntryPoint)
// csrf自行百度
.and().csrf().disable();
return http.build();
}
/**
* ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication
* 需要把jwt的Claim中的authorities加入
* 方案:重新定义ReactiveAuthenticationManager权限管理器,默认转换器JwtGrantedAuthoritiesConverter
*/
@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
// 将认证服务中返回的用户对象信息保存在JWT中,即自主实现的UserDetailsService返回的对象权限加载到JWT中
// UserDetailsService返回对象中的权限添加到jwt中,并加上前缀
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
// 通过authorities字段从jwt中获取UserDetailsService返回的对象中的权限
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
}
package top.sclf.gateway.filter;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.nimbusds.jose.JWSObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import top.sclf.common.core.constant.AuthConstants;
import top.sclf.common.core.exception.CustomException;
import top.sclf.common.core.http.ResultEntity;
import top.sclf.common.core.http.ResultEnum;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
/**
* @author zhangxing
* @date 2021/4/7
*/
@Component
public class BlackListFilter implements WebFilter {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst(AuthConstants.HEADER);
if (StrUtil.isEmpty(token)) {
return chain.filter(exchange);
}
String realToken = token.replace("Bearer ", "");
JWSObject jwsObject;
try {
// 从token中解析用户信息并设置到Header中去
jwsObject = JWSObject.parse(realToken);
} catch (ParseException e) {
throw new CustomException(ResultEnum.SERVER_ERROR, "token解析错误");
}
String payloadStr = jwsObject.getPayload().toString();
JSONObject payload = JSONUtil.parseObj(payloadStr);
// 校验该token是否存在于黑名单中(登出、修改密码)
// JWT唯一标识
String jti = payload.getStr("jti");
Boolean isBlack = redisTemplate.hasKey(AuthConstants.TOKEN_BLACKLIST_PREFIX + jti);
if (Boolean.TRUE.equals(isBlack)) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
response.getHeaders().set("Access-Control-Allow-Origin", "*");
response.getHeaders().set("Cache-Control", "no-cache");
String body = JSONUtil.toJsonStr(ResultEntity.fail(ResultEnum.TOKEN_EXPIRED, ResultEnum.TOKEN_EXPIRED.getMessage()));
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
return chain.filter(exchange);
}
}
package top.sclf.gateway.handler;
import cn.hutool.json.JSONUtil;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import top.sclf.common.core.http.ResultEntity;
import top.sclf.common.core.http.ResultEnum;
import java.nio.charset.StandardCharsets;
/**
* 无权访问自定义响应
*/
@Component
public class CustomServerAccessDeniedHandler implements ServerAccessDeniedHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException e) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
response.getHeaders().set("Access-Control-Allow-Origin", "*");
response.getHeaders().set("Cache-Control", "no-cache");
String body = JSONUtil.toJsonStr(ResultEntity.fail(ResultEnum.NOT_PERMISSION.getCode(), ResultEnum.NOT_PERMISSION.getMessage()));
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
}
package top.sclf.gateway.handler;
import cn.hutool.json.JSONUtil;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import top.sclf.common.core.http.ResultEntity;
import top.sclf.common.core.http.ResultEnum;
import top.sclf.common.core.util.StringUtils;
import java.nio.charset.StandardCharsets;
/**
* 无效token/token过期 自定义响应
*
* @author zhangxing
*/
@Component
public class CustomServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
@Override
public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
response.getHeaders().set("Access-Control-Allow-Origin", "*");
response.getHeaders().set("Cache-Control", "no-cache");
ResultEntity<String> fail = ResultEntity.fail(ResultEnum.TOKEN_INVALID.getCode().intValue(), "未登陆或登录已过期");
// 文章下面会详细描述为什么此处需要判断是否是jwt过期的情况
String message = e.getMessage();
if (message != null && StringUtils.containsIgnoreCase(message, "Jwt expired")) {
fail.setCode(ResultEnum.TOKEN_EXPIRED.getCode());
}
String body = JSONUtil.toJsonStr(fail);
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
}
权限校验失败的自定义响应中,我们判断了是否是应为jwt过期导致的鉴权失败
当请求进来时,如果jwt过期,我们定制一个和前端约定好的错误编码来表示jwt过期,当前端请求访问失败,并且发现响应编码是因为jwt过期导致的请求失败,则前端使用refresh_token
使用刷新token的请求方式来重新获取一次新的授权JWT,返回新JWT后重新设置到请求头上再次发起刚才鉴权失败的请求即可
在网关模块的配置文件中加入
spring:
security:
oauth2:
resourceserver:
jwt:
# 网上找了一圈没有找到此处配置负载均衡的方式,似乎不支持
# 所以此处配置的获取公钥的地址直接访问的网关的地址,曲线救国的方式实现负载均衡
jwk-set-uri: http://localhost:8088/auth/getPublicKey # /auth是我的认证模块的context-path
登录获取Token端点: POST /oauth/token
,/auth
是我的认证模块context-path
返回值:
刷新Token端点: POST /oauth/token
,/auth
是我的认证模块context-path