微服务认证鉴权gateway+oauth2+security+jwt

文章目录

  • 本文认证鉴权思路方案
  • 一. 认证服务器
    • 1. 需要依赖
    • 2. 编写认证服务
    • 3. 安全配置
    • 4. 开放接口配置
  • 二. 资源服务器(此处可理解为鉴权服务)
    • 1. 需要依赖
    • 2. 编写鉴权管理器
    • 3. 编写资源服务
    • 3. 黑名单过滤器
    • 4. 异常处理
    • 5. JWT刷新方案
    • 6. 配置网关模块调用认证模块获取jwt加密公钥地址
  • 三. 配置完毕,开始测试
    • 1. 获取Token
    • 2. 刷新Token
    • 3. 携带Token访问资源
    • 4. 退出登录
    • 5. 退出登录后再次访问资源

本文认证鉴权思路方案

微服务认证鉴权gateway+oauth2+security+jwt_第1张图片
实现思路受到开源电商项目mallyoulai-mall启发,此处贴上他们的开源地址

mall: https://gitee.com/macrozheng/mall
youlai-mall: https://gitee.com/youlaitech/youlai-mall

该篇内容主要为了提升自己对oauth2技术的理解、记录走过的坑的一些解决方案以及对网上零散实现方式的整合

用户登录,网关远程调用认证授权服务完成登录,办法token,使用jwt,本文仅为password认证模式,其他模式请了解Oauth的授权模式后自行百度,大同小异

当网关收到客户端请求的时候,验证用户Token是否正确,正确则校验用户是否具备当前请求路径的权限

内部服务之间裸奔,不校验权限

一. 认证服务器

1. 需要依赖

<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>

2. 编写认证服务

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 请求方式没有测试过,用于检查令牌有效性

3. 安全配置

由于使用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();
    }

}

4. 开放接口配置

关于/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;
    }
}

二. 资源服务器(此处可理解为鉴权服务)

1. 需要依赖

<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>

2. 编写鉴权管理器

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 resourceRolesMap = redisTemplate.opsForHash().entries(AuthConstants.RESOURCE_ROLES_KEY);
        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项目,可以根据自己的业务修改

3. 编写资源服务

资源服务需要申明哪些资源需要被保护起来,哪些资源放行,以及被保护起来的资源的保护逻辑(鉴权管理器)

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);
    }

}

3. 黑名单过滤器

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);
    }
}

4. 异常处理

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));
    }

}

5. JWT刷新方案

权限校验失败的自定义响应中,我们判断了是否是应为jwt过期导致的鉴权失败
当请求进来时,如果jwt过期,我们定制一个和前端约定好的错误编码来表示jwt过期,当前端请求访问失败,并且发现响应编码是因为jwt过期导致的请求失败,则前端使用refresh_token使用刷新token的请求方式来重新获取一次新的授权JWT,返回新JWT后重新设置到请求头上再次发起刚才鉴权失败的请求即可

6. 配置网关模块调用认证模块获取jwt加密公钥地址

在网关模块的配置文件中加入

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
		  # 网上找了一圈没有找到此处配置负载均衡的方式,似乎不支持
		  # 所以此处配置的获取公钥的地址直接访问的网关的地址,曲线救国的方式实现负载均衡
          jwk-set-uri: http://localhost:8088/auth/getPublicKey # /auth是我的认证模块的context-path

三. 配置完毕,开始测试

1. 获取Token

登录获取Token端点: POST /oauth/token,/auth是我的认证模块context-path
微服务认证鉴权gateway+oauth2+security+jwt_第2张图片
返回值:

  • access_token用于访问资源
  • refresh_token用于刷新token,以获取新的access_token

2. 刷新Token

刷新Token端点: POST /oauth/token,/auth是我的认证模块context-path
微服务认证鉴权gateway+oauth2+security+jwt_第3张图片

3. 携带Token访问资源

微服务认证鉴权gateway+oauth2+security+jwt_第4张图片

4. 退出登录

微服务认证鉴权gateway+oauth2+security+jwt_第5张图片

5. 退出登录后再次访问资源

微服务认证鉴权gateway+oauth2+security+jwt_第6张图片

你可能感兴趣的:(spring,负载均衡,java,gateway,oauth2,jwt)