一、前言
我们知道想要实时监控我们的应用程序的运行状态,比如实时显示一些指标数据,观察每时每刻访问的流量,或者是我们数据库的访问状态等等,需要使用到Actuator
组件,但是Actuator
有一个访问未授权问题
,简单说就是其他人可以通过Actuator
组件暴露的URL进行端点信息访问,甚至shutdown应用。那么我们有没有什么解决方法呢?
二、解决方案(Actuator端口与应用端口一致)
我们创建一个spring Boot项目进行演示说明。思路就是对spring Boot actuator暴露的URL访问时,增加携带用户名、密码,同时增加一个Filter进行拦截,为了防止密码泄露,需要对密码进行加密配置,由于后端需要进行对比密码,所以我们需要采用对称加密,这里我们采用SM4加密算法,可以参考博文:
使用SM4国密加密算法对Spring Boot项目数据库连接信息以及yaml文件配置属性进行加密配置(读取时自动解密)
为什么不采用主流的集成Spring Security组件呢,出于两方面考虑:
- 集成Spring Security相对Filter来说比较重量级
- 集成Spring Security进行Actuator端点认证可能会与原有业务安全认证冲突
2.1 创建spring Boot项目,导入相关依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency><groupId>org.bouncycastle</groupId><artifactId>bcprov-jdk15to18</artifactId><version>1.76</version>
</dependency><dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.25</version>
</dependency>
2.2 增加相关配置
management:endpoints:web:exposure:include: health
2.3 启动验证
2.4 授权改造
2.4.1 增加自定义配置
management:endpoints:web:exposure:include: healthwhiteUrl: # 白名单,配置白名单的URL请求时不需要验证,多个可以用英文逗号分隔user: admin # 认证用户password: '@SM4@-HUYu9S6osKi65pZr7YQO9w==' # 认证用户密码 SM4Utils.encryptStr("password")
2.4.2 自定义授权过滤器逻辑
package com.learn.filter;import com.fasterxml.jackson.databind.ObjectMapper;
import com.learn.SM4Utils;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import sun.misc.BASE64Decoder;import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;public class ActuatorFilter implements Filter {private String[] whiteUrl;private String user;private String password;public ActuatorFilter(String whiteUrl, String user, String password) {this.whiteUrl = whiteUrl == null ? null : whiteUrl.split(",");this.user = user;this.password = password;}@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {boolean flag = false;HttpServletRequest httpServletRequest = ((HttpServletRequest) servletRequest);String url = httpServletRequest.getRequestURI();// 判断是否是白名单if (whiteUrl != null && whiteUrl.length != 0) {for (String str : whiteUrl) {if (url.equals(str)) {flag = true;break;}}}if (flag) { // 如果是白名单则直接放行filterChain.doFilter(servletRequest, servletResponse);} else {if (StringUtils.hasText(user) && StringUtils.hasText(password)) { // 如果配置用户名密码String authorization = httpServletRequest.getHeader("authorization");if (StringUtils.hasText(authorization)) { // Basic YWRtaW46YWRtaW4=user = SM4Utils.decryptStr(user);password = SM4Utils.decryptStr(password); // @Value注入可以自动解密,这里手动解密String auth = authorization.replace("Basic ", "");auth = new String(new BASE64Decoder().decodeBuffer(auth), StandardCharsets.UTF_8);String[] authArr = auth.split(":");if (user.equals(authArr[0]) && password.equals(authArr[1])) { //如果用户名密码都可以匹配上则进行放行filterChain.doFilter(servletRequest, servletResponse);} else {errorHandler((HttpServletResponse) servletResponse, "user or password info error!");return;}} else {errorHandler((HttpServletResponse) servletResponse, "Authorization info must be not null");return;}} else { // 如果没有配置用户名密码则直接放行filterChain.doFilter(servletRequest, servletResponse);}}}/*** 异常处理** @param response* @param msg* @throws IOException*/private void errorHandler(HttpServletResponse response, String msg) throws IOException {response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);ObjectMapper objectMapper = new ObjectMapper();PrintWriter writer = response.getWriter();writer.write(objectMapper.writeValueAsString(msg));writer.close();}
}
2.4.3 注册Filter生效
import com.learn.filter.ActuatorFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;@Configuration
public class ActuatorConfig {@Value("${management.endpoints.web.exposure.whiteUrl:}")private String whiteUrl;@Value("${management.endpoints.user:}")private String user;@Value("${management.endpoints.password:}")private String password;@Beanpublic FilterRegistrationBean filterRegistrationBean() {FilterRegistrationBean<ActuatorFilter> filterRegistrationBean = new FilterRegistrationBean<>();filterRegistrationBean.setFilter(new ActuatorFilter(whiteUrl, user, password));filterRegistrationBean.addUrlPatterns("/actuator/*");filterRegistrationBean.setName("ActuatorFilter");return filterRegistrationBean;}}
2.5 功能测试
-
成功
注意:URL格式:http://明文user:明文password@ip:port/actuator/health
前端访问时需要携带密码,后端配置密码进行了加密,防止密码泄露。
-
失败
此时访问actuator端点都需要携带用户名密码了。
三、解决方案(Actuator端口与应用端口不一致)
众所周知,spring Boot Actuator组件的端口是可以自定义配置的,如果是自定义配置端口,那么上面的Filter不会生效,那么要怎么处理呢。
测试密码错误,此时还是会正常返回:
3.1 增加自定义端口配置
server:port: 8080management:server:port: 9999endpoints:web:exposure:include: healthwhiteUrl: # 白名单,配置白名单的URL请求时不需要验证,多个可以用英文逗号分隔user: admin # 认证用户password: '@SM4@-HUYu9S6osKi65pZr7YQO9w==' # 认证用户密码 SM4Utils.encryptStr("password")
3.2 修改Filter逻辑,增加Actuator端口判断
import com.fasterxml.jackson.databind.ObjectMapper;
import com.learn.SM4Utils;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import sun.misc.BASE64Decoder;import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;public class ActuatorFilter implements Filter {private String[] whiteUrl;private String user;private String password;/*** actuator端口*/private Integer actuatorPort;public ActuatorFilter(String whiteUrl, String user, String password, Integer actuatorPort) {this.whiteUrl = whiteUrl == null ? null : whiteUrl.split(",");this.user = user;this.password = password;this.actuatorPort = actuatorPort;}@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {HttpServletRequest httpServletRequest = ((HttpServletRequest) servletRequest);int serverPort = httpServletRequest.getServerPort();if (actuatorPort != null && serverPort == actuatorPort) { // 判断是否是actuator端口boolean flag = false;String url = httpServletRequest.getRequestURI();// 判断是否是白名单if (whiteUrl != null && whiteUrl.length != 0) {for (String str : whiteUrl) {if (url.equals(str)) {flag = true;break;}}}if (flag) { // 如果是白名单则直接放行filterChain.doFilter(servletRequest, servletResponse);} else {if (StringUtils.hasText(user) && StringUtils.hasText(password)) { // 如果配置用户名密码String authorization = httpServletRequest.getHeader("authorization");if (StringUtils.hasText(authorization)) { // Basic YWRtaW46YWRtaW4=user = SM4Utils.decryptStr(user);password = SM4Utils.decryptStr(password); // @Value注入可以自动解密,这里手动解密String auth = authorization.replace("Basic ", "");auth = new String(new BASE64Decoder().decodeBuffer(auth), StandardCharsets.UTF_8);String[] authArr = auth.split(":");if (user.equals(authArr[0]) && password.equals(authArr[1])) { //如果用户名密码都可以匹配上则进行放行filterChain.doFilter(servletRequest, servletResponse);} else {errorHandler((HttpServletResponse) servletResponse, "user or password info error!");return;}} else {errorHandler((HttpServletResponse) servletResponse, "Authorization info must be not null");return;}} else { // 如果没有配置用户名密码则直接放行filterChain.doFilter(servletRequest, servletResponse);}}} else {filterChain.doFilter(servletRequest, servletResponse);}}/*** 异常处理** @param response* @param msg* @throws IOException*/private void errorHandler(HttpServletResponse response, String msg) throws IOException {response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);ObjectMapper objectMapper = new ObjectMapper();PrintWriter writer = response.getWriter();writer.write(objectMapper.writeValueAsString(msg));writer.close();}
}
3.3 修改Filter注册逻辑
import com.learn.filter.ActuatorFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;@Configuration
public class ActuatorConfig {@Beanpublic ActuatorFilter actuatorFilter(Environment environment) {String whiteUrl = environment.getProperty("management.endpoints.web.exposure.whiteUrl");String user = environment.getProperty("management.endpoints.user");String password = environment.getProperty("management.endpoints.password");String portStr = environment.getProperty("management.server.port");Integer port = portStr == null ? null : Integer.parseInt(portStr);return new ActuatorFilter(whiteUrl, user, password, port);}}
3.4 注册ManagementContextConfiguration
这是最重要的一步,需要配置ManagementContextConfiguration,不然Filter依旧不会生效:
在resource目录下新建META-INF目录,新建spring.factories文件
增加内容:
org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration=\com.learn.config.ActuatorConfig
3.5 测试
此时密码错误情况下就进行拦截了