在SpringBoot Gateway 中实现RedisToken - > JwtToken的token交换

背景

  1. jwt token的载荷是明文(base64),虽然只是用来传递一些非敏感信息,但依旧会让人感觉有些不适
  2. jwt token无法主动失效
  3. 微服务之间尽量减少耦合度

解决思路

  1. 由认证服务(iam)产生RedisToken,该token保存在iam服务的redis中,可以主动失效,也可以设置失效时间
  2. 采用jwt作为微服务间验证的依据,如果使用RedisToken,则所有微服务均需要依赖同一个redis数据库(或集群)

实现方式

  1. 用户登陆,由 认证服务 产生RedisToken交给用户
  2. 用户使用RedisToken通过 网关服务 访问 业务服务,在 网关服务 中使用RedisToken交换JwtToken(可以包含一些非敏感的当前用户的信息),再使用JwtToken访问业务服务
  3. 业务服务只对JwtToken进行验证,并且可以从jwt payload中解析出当前用户的信息

代码实现

注册中心

eureka、nacos等,略

认证服务

使用SpringSecurity实现用户登陆

参考文档:https://www.cnblogs.com/cbvlog/p/15624215.html

  1. 依赖注入

其他如数据库,redis,服务发现,服务调用等依赖就不写了

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
  1. 设置用户密码加密方式
/**
 * 密码加密方式
 *
 * @author Jenson
 */
@Component
@Slf4j
public class CustomBCryptPasswordEncoder implements PasswordEncoder {

    @Override
    public String encode(CharSequence rawPassword) {
        // 简单加密,生成一个salt
        String salt = BCrypt.gensalt();
        return BCrypt.hashpw(rawPassword.toString(), salt);
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if (rawPassword != null && encodedPassword != null && !encodedPassword.isEmpty()) {
            return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
        } else {
            log.warn("Empty encoded password");
            return false;
        }
    }
}

  1. 登录认证过滤器,设置登录地址、调用方式,继承 org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter
/**
 * 登录认证过滤器
 *
 * @author Jenson
 */
public class AuthenticationLoginFilter extends AbstractAuthenticationProcessingFilter {

    /**
     * 设置登录地址、调用方式
     */
    public AuthenticationLoginFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 读取表单提取数据
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        // 封装到token中提交
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        return getAuthenticationManager().authenticate(authRequest);
    }
}

  1. 实现 org.springframework.security.core.userdetails.UserDetailsService

验证登陆接口中用户传入的账号密码,生成org.springframework.security.core.userdetails.UserDetails

/**
 * @author Jenson
 */
@Slf4j
@Service
public class CustomUserDetailsServiceImpl implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 使用用户名查询数据库(或是缓存中的)中的用户持久层对象 UserPO(名字随便了)
        UserPO userPo = this.searchUserPoFromDb(username);
        if(userPo == null){
            // 用户不存在,登录失败
            return null;
        }
        // 构建 org.springframework.security.core.userdetails.User 对象,当然最好是继承它,可以添加一些自己的属性上去,比如生日年龄性别啥的
        User  user =new User(userPo.getUsername,
                             userPo.getPassword, 
                             AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
        return user ;
    }
}

  1. 实现 认证成功处理器,org.springframework.security.web.authentication.AuthenticationSuccessHandler

认证成功后,生成token,输出token

/**
 * 认证成功处理器
 *
 * @author Jenson
 */
@Slf4j
@Component
public class LoginAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    
    // 这是自己写的redis-token工具对象
    @Autowired
    private RedisTokenUtils redisTokenUtils;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        User user = (User) authentication.getPrincipal();
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 根据获取的用户信息生成token,并将token保存在redis中,设置失效时间,
        // 考虑到登陆时,系统中可能有未失效的token,为了避免多端登陆互相踢出,可以先尝试获取用户的token,
        // 如果存在则刷新缓存时间(token续期),更新token绑定用户缓存信息,返回老token;如果不存在则沈城新token
        UserTokenRel userTokenRel = redisTokenUtils.refreshToken(user);
        // 书写响应体
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpStatus.OK.value());
        // 在响应体中写出token
        byte[] body = JSON.toJSONString(userTokenRel.getLoginToken()).getBytes(StandardCharsets.UTF_8);
        OutputStream outputStream = response.getOutputStream();
        try {
            outputStream.write(body);
        } finally {
            outputStream.flush();
            outputStream.close();
        }

    }
}
  1. 实现 认证失败处理器,org.springframework.security.web.authentication.AuthenticationFailureHandler
/**
 * 认证失败处理
 *
 * @author Jenson
 */
@Slf4j
@Component
public class LoginAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        String exceptionMsg = "认证失败原因";
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        OutputStream outputStream = response.getOutputStream();
        // 自定义的响应体
        CuxResponseEntity<ExceptionResponse> responseEntity =
                new CuxResponseEntity<>(new ExceptionResponse("AUTHENTICATION_FAILURE", exceptionMsg), new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR);
        byte[] body = JSON.toJSONString(responseEntity.getBody()).getBytes(StandardCharsets.UTF_8);
        try {
            outputStream.write(body);
        } finally {
            outputStream.flush();
            outputStream.close();
        }
    }
}

  1. 登录过滤器的配置

需要使用到上述创建的三个实例

/**
 * 登录过滤器的配置
 *
 * @author Jenson
 */
@Configuration
public class AuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    /**
     * userDetailService
     */
    @Qualifier("customUserDetailsServiceImpl")
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 登录成功处理器
     */
    @Autowired
    private AuthenticationSuccessHandler loginAuthenticationSuccessHandler;

    /**
     * 登录失败处理器
     */
    @Autowired
    private AuthenticationFailureHandler loginAuthenticationFailureHandler;

    /**
     * 加密
     */
    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * 将登录接口的过滤器配置到过滤器链中
     * 1. 配置登录成功、失败处理器
     * 2. 配置自定义的userDetailService(从数据库中获取用户数据)
     * 3. 将自定义的过滤器配置到spring security的过滤器链中,配置在UsernamePasswordAuthenticationFilter之前
     *
     * @param http HttpSecurity
     */
    @Override
    public void configure(HttpSecurity http) {
        AuthenticationLoginFilter filter = new AuthenticationLoginFilter();
        filter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        //认证成功处理器
        filter.setAuthenticationSuccessHandler(loginAuthenticationSuccessHandler);
        //认证失败处理器
        filter.setAuthenticationFailureHandler(loginAuthenticationFailureHandler);
        //直接使用DaoAuthenticationProvider
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        //设置userDetailService
        provider.setUserDetailsService(userDetailsService);
        //设置加密算法
        provider.setPasswordEncoder(passwordEncoder);
        http.authenticationProvider(provider);
        //将这个过滤器添加到UsernamePasswordAuthenticationFilter之前执行
        http.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
    }
}

  1. Token校验过滤器

此处的token,是jwt-token,在上述“认证成功过滤器”中,发放的是redis-token,但是使用redis-token调用网关接口时,网关会使用redis-token交换jwt-token,所以该认证服务的filter需要认证的是jwt-token

/**
 * Token校验过滤器
 *
 * @author Jenson
 */
@Slf4j
public class TokenAuthenticationFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        HttpServletRequest httpServletRequest;
        httpServletRequest = (HttpServletRequest) request;
        String authorization = httpServletRequest.getHeader("Authorization");
        log.info("---------> Authorization : {}", authorization);
        if (StringUtils.hasText(authorization)) {

            String[] tokenDetail = authorization.trim().split(BaseConstants.Symbol.SPACE);
            String token = tokenDetail[1];
            if (StringUtils.hasText(token)) {
                User user = null;
                try {
                    // JwtUtils 是自定义的jwt-token解析工具
                    // 解析jwt-token,创建用户对象
                    // HttpServletResponseUtils 是自定义的异常打印工具类
                    User = new User(JwtUtils.verify(token));
                } catch (UnauthorizedException e) {
                    HttpServletResponseUtils.outPrintUnauthorizedException(httpServletResponse);
                    return;
                } catch (ForbiddenException e) {
                    HttpServletResponseUtils.outPrintForbiddenException(httpServletResponse);
                    return;
                } catch (Exception e) {
                    HttpServletResponseUtils.outPrintUnknownException(httpServletResponse);
                    return;
                }
                // 账号不为空且还没有认证过
                if (user !=null && SecurityContextHolder.getContext().getAuthentication() == null) {
                    // 认证成功,设置当前用户对象
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
}

该过滤器没有像参考文档一样继承OncePerRequestFilter,以为我实际使用中遇到了非自定义异常时,又会过一遍filter才抛出(不知道是不是哪配置错了),而这次过filter时filter调用链中没有了认证的filter,就会抛出认证失败的异常,将原异常覆盖。

  1. 接口未认证异常
/**
 * 用户未通过认证访问受保护的资源 401
 *
 * @author Jenson
 */
@Slf4j
@Component
public class EntryPointUauthenticationHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        authException.printStackTrace();
        // 自定义异常打印工具
        HttpServletResponseUtils.outPrintUnauthorizedException(response);
    }
}

  1. 接口认证无权限异常

/**
 * 认证成功的用户访问受保护的资源,但是权限不够 403
 *
 * @author Jenson
 */
@Slf4j
@Component
public class RequestAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        // 自定义异常打印工具
        HttpServletResponseUtils.outPrintForbiddenException(response);
    }
}
  1. 接口授权配置

/**
 * @author Jenson
 */
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private AuthenticationSecurityConfig authenticationSecurityConfig;
    @Qualifier("entryPointUauthenticationHandler")
    @Autowired
    private AuthenticationEntryPoint entryPointUauthenticationHandler;
    @Qualifier("requestAccessDeniedHandler")
    @Autowired
    private AccessDeniedHandler requestAccessDeniedHandler;

    /**
     * 授权配置,最高优先级
     *
     * @param http HttpSecurity
     * @return SecurityFilterChain
     * @throws Exception
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {

        http
                // 禁用表单登录
                .formLogin().disable()
                // 应用登录过滤器的配置,配置分离
                .apply(authenticationSecurityConfig)
                .and()
                // 设置URL的授权
                .authorizeHttpRequests()
                // 这里需要将登录页面放行,permitAll()表示不再拦截,/login 登录的url,/refreshToken刷新token的url
                .requestMatchers(
                        // 登陆接口
                        "/login",
                        // token交换接口(redis-token -> jwt-token)
                        "/token/generate/jwt/{redisToken}"
                )
                .permitAll()
                .anyRequest()
                .authenticated()
                //处理异常情况:认证失败和权限不足
                .and()
                .exceptionHandling()
                //认证未通过,不允许访问异常处理器
                .authenticationEntryPoint(entryPointUauthenticationHandler)
                //认证通过,但是没权限处理器
                .accessDeniedHandler(requestAccessDeniedHandler)
                .and()
                //禁用session,JWT校验不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //将TOKEN校验过滤器配置到过滤器链中,否则不生效,放到UsernamePasswordAuthenticationFilter之前
                .addFilterAt(new TokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                // 解决跨域问题(其实没有解决)
                .cors()
                .and()
                // 关闭csrf
                .csrf().disable();

        return http.build();
    }

}

将登陆接口(/login)和 token交换接口(/token/generate/jwt/{redisToken}) 设置为免登录。
/token/generate/jwt/{redisToken}其实是需要认证的,但是filter配置为了认证jwt-token,当然也可以在filter中增加一种认证方式,但我这里选择在容器内认证,所以该接口设置为免登录。

  1. token 交换接口 /token/generate/jwt/{redisToken}
 /**
     * 生成JWT
     *
     * @param redisToken redisToken
     * @return jwt
     */
    @GetMapping("/generate/jwt/{redisToken}")
    public WmResponseEntity<String> generateJwt(@PathVariable String redisToken) {

        log.info("-----> generateJwt redisToken : {}", redisToken);
        // 根据 redisToken 获取用户信息 ,redisTokenUtils 为自定义用户redis-token管理工具
        UserTokenRel userTokenRel = redisTokenUtils.getUserTokenRel(redisToken);
        if (userTokenRel == null) {
            // CommonException 为自定义 RuntimeException
            throw new CommonException("NOT_LOGGED_IN", "用户未登录");
        }
        User user = userService.searchUserByUserId(userTokenRel.getUserId());
        // 生成 jwt-token,jwt的payload中可以尽可能装入除用户密码外的用户信息,
        // 在业务服务中进行解析,就不需要跨服务获取调用者信息了,实现了服务解耦
        String jwt = tokenService.generateJwt(user);
        // 异步刷新一下token,避免使用中失效
        Long userId = user.getUserId();
        // 异步刷新token,token自动续期,防止用户用着用着突然掉线
        CompletableFuture.runAsync(() -> redisTokenUtils.refreshToken(userId), commonExecutor);
        return Results.success(jwt);
    }

网关

  1. 依赖注入

其他工具依赖就省略了

         <!-- gateway-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
            <version>4.0.3</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

  1. gateway 路由配置
    无论采用静态路由配置还是动态路由配置,不在此处细说
    配置认证服务的路由前缀为 /iam,则登陆接口地址就是 /iam/login

spring-gateway+nacos 实现动态路由配置可以参考以下文章:
https://blog.csdn.net/qq_38374397/article/details/125874882

  1. 实现 GlobalFilter ,拦截请求进行客户化处理
    自定义filter,在网关路由前调用认证服务接口交换token(只能异步调用),使用jwt调用服务
/**
 * 网关请求拦截客户化处理
 *
 * @author Jenson
 * @version 1.0
 */
@Slf4j
@Component
public class CuxGlobalFilter implements GlobalFilter, Ordered {

    @SneakyThrows
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        String path = request.getURI().getPath();
        log.info("-----------> path : {}", path);
        HttpHeaders headers = request.getHeaders();
        String authorization = headers.getFirst("Authorization");
        String tenant = headers.getFirst("Tenant");
        if (!"/iam/login".equals(path) && StringUtils.hasText(authorization)) {
            // 非 认证 服务,换用 jwt token,此举是为了在认证层面解耦服务
            headers = HttpHeaders.writableHttpHeaders(headers);
            String[] tokenDetail = authorization.trim().split(BaseConstants.Symbol.SPACE);
            String token = tokenDetail[1];
            if (!StringUtils.hasText(token)) {
                return outUnauthorizedResponse(response);
            }

            // ------ 获取Token start -----
            // 在此处,异步 feign调用 交换 token 接口,获得新jwt-token
            CompletableFuture<ResponseEntity<String>> newJwtFuture = CompletableFuture.supplyAsync(() -> tokenService.generateJwt(token));
            String jwt;
            try {
                ResponseEntity<String> responseEntity = newJwtFuture.get(1, TimeUnit.SECONDS);
                // FeignRspEntityParseUtils 自定义接口响应结果解析工具
                jwt = FeignRspEntityParseUtils.parse(responseEntity, String.class);
            } catch (CommonException e) {
                if ("NOT_LOGGED_IN".equals(e.getCode())) {
                    return outUnauthorizedResponse(response);
                }
                return outCommonExceptionResponse(response, e.getCode(), e.getMsg());
            } catch (Exception e) {
                log.info("----> 换取 jwt 失败 , {}", e);
                return outCommonExceptionResponse(response, "SWITCH_JWT_ERROR", "换取 jwt 失败 ,请联系管理员检查认证服务");
            }
            log.info("-----> jwt : {}", jwt);
            // ---- 在这里获取Token end -----

            headers.setBearerAuth(jwt);
        }
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 1;
    }

    /**
     * 输出未认证响应
     *
     * @param response 响应体
     * @return 未认证
     * @throws JsonProcessingException json解析异常
     */
    private Mono<Void> outUnauthorizedResponse(ServerHttpResponse response) throws JsonProcessingException {
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        DataBufferFactory bufferFactory = response.bufferFactory();
        ObjectMapper objectMapper = new ObjectMapper();
        CuxResponseEntity<ExceptionResponse> responseEntity =
                new CuxResponseEntity<>(new ExceptionResponse("UNAUTHORIZED", "Unauthorized !"), new HttpHeaders(), HttpStatus.UNAUTHORIZED);
        DataBuffer wrap = bufferFactory.wrap(objectMapper.writeValueAsBytes(responseEntity.getBody()));
        return response.writeWith(Mono.fromSupplier(() -> wrap));
    }

    /**
     * 输出通用异常响应信息
     *
     * @param response 响应体
     * @return 未认证
     * @throws JsonProcessingException json解析异常
     */
    private Mono<Void> outCommonExceptionResponse(ServerHttpResponse response, String code, String msg) throws JsonProcessingException {
        response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
        DataBufferFactory bufferFactory = response.bufferFactory();
        ObjectMapper objectMapper = new ObjectMapper();
        CuxResponseEntity<ExceptionResponse> responseEntity =
                new CuxResponseEntity<>(new ExceptionResponse(code, msg), new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR);
        DataBuffer wrap = bufferFactory.wrap(objectMapper.writeValueAsBytes(responseEntity.getBody()));
        return response.writeWith(Mono.fromSupplier(() -> wrap));
    }
}

  1. 自定义异常处理(非必须)
    将网关抛的错改造为自定义的异常格式,方便前端处理
/**
 * 自定义异常处理
 *
 * @author Jenson
 * @version 1.0
 */
@Slf4j
@Configuration
@Order(-1)
public class CuxWebExceptionHandler implements WebExceptionHandler {
    @SneakyThrows
    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        ServerHttpResponse response = exchange.getResponse();
        if (response.isCommitted()) {
            return Mono.error(ex);
        } else if (ex instanceof ResponseStatusException rspEx) {
            HttpStatusCode httpStatusCode = rspEx.getStatusCode();
            response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
            response.setStatusCode(httpStatusCode);
            DataBufferFactory bufferFactory = response.bufferFactory();
            ObjectMapper objectMapper = new ObjectMapper();
            CuxResponseEntity<ExceptionResponse> responseEntity =
                    new CuxResponseEntity<>(new ExceptionResponse(((HttpStatus) httpStatusCode).name(), rspEx.getReason()),
                            rspEx.getHeaders(), httpStatusCode.value());
            DataBuffer wrap = bufferFactory.wrap(objectMapper.writeValueAsBytes(responseEntity.getBody()));
            return response.writeWith(Mono.fromSupplier(() -> wrap));
        } else {
            return Mono.error(ex);
        }
    }
}

  1. 解决跨域
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.util.pattern.PathPatternParser;

/**
 * 跨域
 *
 * @author Jenson
 * @version 1.0
 */
@Configuration
public class CorsConfig {

    @Bean
    public CorsWebFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        // 允许的请求头
        config.addAllowedMethod("*");
        // 允许的请求源 (如:http://localhost:8080)
        config.addAllowedOrigin("*");
        // 允许的请求方法 ==> GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE
        config.addAllowedHeader("*");
        // URL 映射 (如: /admin/**)
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
        source.registerCorsConfiguration("/**", config);
        return new CorsWebFilter(source);
    }
}


业务服务

业务服务的认证逻辑都是统一的,所以采用依赖starter组件的方式,就可以快速为每一个业务服务增加接口认证

  1. 构建cux-start-core工程
  • pom.xml 依赖注入
......
<groupId>com.cux</groupId>
    <artifactId>cux-starter-core</artifactId>
    <version>1.0-SNAPSHOT</version>

    <name>cux-starter-core</name>

<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        ......
  1. 接口认证filter(jwt)

此处代码逻辑和认证服务是一致的,其实可以考虑将此核心依赖包运用于认证服务

/**
 * Token校验过滤器
 *
 * @author Jenson
 */
@Slf4j
public class TokenAuthenticationFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        HttpServletRequest httpServletRequest;
        httpServletRequest = (HttpServletRequest) request;
        String authorization = httpServletRequest.getHeader("Authorization");
        log.info("---------> Authorization : {}", authorization);
        if (StringUtils.hasText(authorization)) {

            String[] tokenDetail = authorization.trim().split(BaseConstants.Symbol.SPACE);
            String token = tokenDetail[1];
            if (StringUtils.hasText(token)) {
                User user = null;
                try {
                    // JwtUtils 是自定义的jwt-token解析工具
                    // 解析jwt-token,创建用户对象
                    // HttpServletResponseUtils 是自定义的异常打印工具类
                    User = new User(JwtUtils.verify(token));
                } catch (UnauthorizedException e) {
                    HttpServletResponseUtils.outPrintUnauthorizedException(httpServletResponse);
                    return;
                } catch (ForbiddenException e) {
                    HttpServletResponseUtils.outPrintForbiddenException(httpServletResponse);
                    return;
                } catch (Exception e) {
                    HttpServletResponseUtils.outPrintUnknownException(httpServletResponse);
                    return;
                }
                // 账号不为空且还没有认证过
                if (user !=null && SecurityContextHolder.getContext().getAuthentication() == null) {
                    // 认证成功,设置当前用户对象
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
}

  1. 接口未认证异常
    此处代码逻辑和认证服务是一致的,其实可以考虑将此核心依赖包运用于认证服务
/**
 * 用户未通过认证访问受保护的资源 401
 *
 * @author Jenson
 */
@Slf4j
@Component
public class EntryPointUauthenticationHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        authException.printStackTrace();
        // 自定义异常打印工具
        HttpServletResponseUtils.outPrintUnauthorizedException(response);
    }
}

  1. 接口认证无权限异常
    此处代码逻辑和认证服务是一致的,其实可以考虑将此核心依赖包运用于认证服务

/**
 * 认证成功的用户访问受保护的资源,但是权限不够 403
 *
 * @author Jenson
 */
@Slf4j
@Component
public class RequestAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        // 自定义异常打印工具
        HttpServletResponseUtils.outPrintForbiddenException(response);
    }
}
  1. 认证配置
/**
 * @author Jenson
 */
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private PermitRequestMatchers permitRequestMatchers;

    /**
     * 授权配置,最高优先级
     *
     * @param http HttpSecurity
     * @return SecurityFilterChain
     * @throws Exception
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {

        http
                // 禁用表单登录
                .formLogin().disable()
                // 设置URL的授权
                .authorizeHttpRequests()
                // 需要放行的url,动态获取免登录接口
                .requestMatchers(permitRequestMatchers.requestMatchersToArray())
                .permitAll()
                .anyRequest()
                .authenticated()
                //处理异常情况:认证失败和权限不足
                .and()
                .exceptionHandling()
                //认证未通过,不允许访问异常处理器
                .authenticationEntryPoint(new EntryPointUauthenticationHandler())
                //认证通过,但是没权限处理器
                .accessDeniedHandler(new RequestAccessDeniedHandler())
                .and()
                //禁用session,JWT校验不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //将TOKEN校验过滤器配置到过滤器链中,否则不生效,放到UsernamePasswordAuthenticationFilter之前
                .addFilterAt(new TokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                // 解决跨域问题
                .cors()
                .and()
                // 关闭csrf
                .csrf().disable();

        return http.build();
    }

}


  1. 服务间调用,token传递(openfeign)

微服务间feign调用是不走网关的,为了互相传递当前调用接口的jwt-token,需要配置feign,token的传递可以进行公共配置

  • feign 依赖
 <!--服务调用-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
            <version>4.0.1</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-loadbalancer</artifactId>
            <version>4.0.1</version>
        </dependency>

  • Feign 请求拦截器
/**
 * Feign 请求拦截器
 * <p>
 * 请求带上token
 *
 * @author Jenson
 */
public class FeignRequestInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes != null) {
            HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
            String authorization = request.getHeader("Authorization");
            requestTemplate.header("Authorization", authorization);
        }
    }
}

/**
 * @author Jenson
 */
@Configuration
public class FeignRequestInterceptorConfig {

    @Bean
    public RequestInterceptor createFeignRequestInterceptor() {
        return new FeignRequestInterceptor();
    }
}
  1. spring.factories 配置文件

定义需要装载的配置类

image.png
  1. 打包,安装依赖

本地安装:mvn install

  1. 将该核心依赖运用于业务服务,即可完成对业务服务的接口权限控制
    业务服务与认证服务、网关需要在同一个注册中心下;
    网关需要配置对应业务服务的路由;
    此时,直接访问业务服务的接口需要jwt-token,通过网关访问,需要redis-token;
    只需将网关暴露到外部而不需要暴露业务服务

其他补充

  1. jwt 生成 和 解析
  • pom.xml 依赖
<dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.5.0</version>
        </dependency>
  • 生成jwt
 /**
     * 生成jwt,设置超时时间
     *
     * @param payload 载荷
     * @return jwt
     */
    public static String generate(Map<String, String> payload) {
        //过期时间
        Date expireDate = new Date(System.currentTimeMillis() + DEFAULT_JWT_TTL);
        Map<String, Object> map = new HashMap<>();
        map.put("alg", "HS256");
        map.put("typ", "JWT");
        JWTCreator.Builder jwtBuilder = JWT.create()
                // 添加头部
                .withHeader(map)
                //超时设置,设置过期的日期
                .withExpiresAt(expireDate)
                //签发时间
                .withIssuedAt(new Date());

        // 构建 jwt 载荷
        payload.forEach(jwtBuilder::withClaim);
        // 签名,返回
        return jwtBuilder.sign(Algorithm.HMAC256(DEFAULT_JWT_SECRET));
    }

  • 解析 jwt
/**
     * 校验token并解析token
     *
     * @return 从token中解析出的载荷
     */
    public static Map<String, Claim> verify(String token) {
        if ("token_absent".equals(token)) {
            // redis token 不存在
            throw new UnauthorizedException();
        }
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(DEFAULT_JWT_SECRET)).build();
            DecodedJWT jwt = verifier.verify(token);
            return jwt.getClaims();
        } catch (TokenExpiredException e) {
            log.error("jwt token已过期");
            throw new UnauthorizedException();
        } catch (JWTVerificationException e) {
            log.error("jwt token不存在或不正确");
            throw new ForbiddenException();
        }
    }

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,539评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,911评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,337评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,723评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,795评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,762评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,742评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,508评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,954评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,247评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,404评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,104评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,736评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,352评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,557评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,371评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,292评论 2 352

推荐阅读更多精彩内容