首页 / 资讯中心 / 文章详情

Spring Boot接口安全实战:Basic Auth认证实现与踩坑指南

Spring Boot接口安全实战:Basic Auth认证实现与踩坑指南 ★ FEATURED ARTICLE
某天下午运维同事发来一张截图某个对外暴露的服务接口被人用脚本循环扫日志里密密麻麻的非预期请求。排查下来原因很简单这个接口为了图方便一直没做任何访问认证等于把家门敞开着。当时的第一反应是先把门锁上再慢慢研究怎么换指纹锁——这个“门锁”就是 Basic Auth。这里说的 Basic Auth是 HTTP 协议里最经典的认证方式在 Spring Boot 项目里用起来非常轻量。只需要在请求头里带上 Authorization服务端校验通过就放行不通过就返回 401。这篇文章我会从实际项目出发把 Spring Boot 上实现 Basic Auth 的完整思路、两种落地写法、联调踩坑清单和安全边界全部讲清楚适合正在做内部系统、微服务接口保护或者临时需要给暴露 API 加认证的后端开发者参考。1. Basic Auth 是什么为什么 2024 年还有人用它很多人一听 Basic Auth 就觉得“过时了”“太简单”但它最大的优点恰恰就是简单。HTTP 协议从 1.0 时代就原生支持这种认证方式所有客户端、网关、浏览器几乎零成本支持不需要额外引入 SDK不需要复杂的握手流程一个请求头就能完成身份校验。1.1 一次认证请求的完整旅程先看一个最直观的例子。假设接口地址是/api/order/list用户名为api-user密码为api-pass-2024。用 curl 访问时命令是这样curl -u api-user:api-pass-2024 http://localhost:8080/api/order/listcurl 的-u参数会自动把用户名:密码这段字符串用 Base64 编码然后在请求头里带上。也就是说实际发出去的 HTTP 请求头长这样GET /api/order/list HTTP/1.1 Host: localhost:8080 Authorization: Basic YXBpLXVzZXI6YXBpLXBhc3MtMjAyNAYXBpLXVzZXI6YXBpLXBhc3MtMjAyNA就是api-user:api-pass-2024的 Base64 编码结果。服务器收到请求后先看请求头里有没有 Authorization再看是不是以Basic开头然后取出后面那段密文用 Base64 解码还原成用户名:密码最后做比对。这里要反复强调一个认知Base64 不是加密它只是一种编码方式和十六进制、二进制本质上没有区别任何人拿到这段字符串都能轻易解码还原。所以 Basic Auth 必须搭配 HTTPS 一起用否则就相当于把用户名密码打印在快递单上寄出去中途任何一个环节被摘抄复制账号就泄露了。1.2 服务端的校验逻辑服务端收到解码后的用户名:密码主要分三步走找用户根据用户名去内存、数据库或第三方系统里查到对应用户信息。验密码把客户端传过来的密码和存储的密码做比对。这里面有个细节数据库存的通常不是明文密码而是 BCrypt 之类的哈希值所以校验逻辑也是拿 BCrypt 去比对而不是直接字符串相等。定权限验证通过后再看这个用户能不能访问当前请求的 API。Spring Security 里这步对应的是authorizeHttpRequests规则。任一环节没通过就返回 401同时带上WWW-Authenticate: Basic realmxxx响应头告诉客户端“你需要提供 Basic 认证信息”。客户端收到 401 后如果是浏览器会弹出登录框如果是 curl 或代码调用就会收到一个明确的失败响应。1.3 哪些场景真正适合 Basic Auth做了几年后端我的经验是选认证方案别盲目追新得先问自己一个问题——“这个 API 是给谁调的调用方有多少”。我用下来觉得合适的场景有这么几类内部系统之间的接口调用比如后台管理系统调用订单服务服务数量有限、调用方可控。给第三方合作方临时开放的测试接口双方约定好账号密码数据不敏感。网关回调接口比如支付回调、短信状态回执这些回调地址暴露在公网上加一道 Basic Auth 能挡掉大量扫描流量。测试环境联调接口不想费劲接一套完整的登录认证体系用个统一账号即可。不适合做什么凡是面向 C 端用户的公开接口比如 App 登录后的数据接口绝对不要用 Basic Auth。因为它没有会话过期、无法控制粒度、没法做多维度授权用户一旦拿到凭据理论上可以一直调用到密码被修改为止。2. 方案选型Spring Security 还是手写 Filter确定了要用 Basic Auth接下来就是怎么在 Spring Boot 里落地。业内无非两条路要么用 Spring Security 内置的认证过滤器要么自己写一个 Filter 手动校验。两条路我都走过下面把各自的适用场景和实现细节摊开来说。2.1 Spring Security 方案最正规代码最省心如果你的项目本来就在用 Spring Security或者你希望后续能平滑升级到更复杂的认证方式直接用它的内置实现是首选。Spring Security 从 5.7 版本开始官方就推荐用SecurityFilterChain的 Bean 方式做配置早期那种继承WebSecurityConfigurerAdapter的写法在新的 Spring Boot 3.x 里已经被移除了。核心配置长这样Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/public/**, /actuator/health, /swagger-ui/**, /v3/api-docs/**).permitAll() .anyRequest().authenticated() ) .httpBasic(Customizer.withDefaults()); return http.build(); } }这段配置里几个关键点我逐个解释一下。csrf.disable()是因为 Basic Auth 走的是请求头认证不是表单登录CSRF 防护在这里没有意义反而会因为缺少 Token 导致后续请求被拒。sessionCreationPolicy(STATELESS)是告诉 Spring Security 不要创建会话因为 Basic Auth 每个请求都自带凭据服务端不需要记忆任何状态这也能减轻服务端内存压力。requestMatchers(...).permitAll()用来放行不需要认证的路径这个后面会细说。httpBasic(Customizer.withDefaults())就是打开 Basic Auth 认证开关。用户信息从哪来最简单的方式是直接定义在内存里适合快速上线和测试环境Bean public UserDetailsService userDetailsService(PasswordEncoder encoder) { UserDetails user User.builder() .username(api-user) .password(encoder.encode(api-pass-2024)) .roles(API) .build(); return new InMemoryUserDetailsManager(user); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }注意password字段存的是 BCrypt 编码后的哈希值不是明文。如果没有PasswordEncoderSpring Security 在启动时会把密码当成{noop}明文处理虽然也能跑但生产环境这么干等于把密码写在配置里非常危险。如果用户信息要从数据库查实现UserDetailsService接口并返回查到的用户即可其他逻辑不用变。2.2 手写 Filter 方案轻量、透明、可控有些老项目引入 Spring Security 会有依赖冲突或者整个服务只有一两个接口需要简单认证为了一个需求拖进来一整套安全框架确实有点重。这种时候手写一个 Filter 其实是最务实的方案代码量不大逻辑也完全透明。下面是一个可以直接抄作业的OncePerRequestFilter实现我注释写得比较细Component public class BasicAuthFilter extends OncePerRequestFilter { // 生产环境这些值应该从配置中心、环境变量或数据库读取 private static final String EXPECTED_USERNAME api-user; private static final String EXPECTED_PASSWORD api-pass-2024; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header null || !header.startsWith(Basic )) { unauthorized(response); return; } try { String base64Credentials header.substring(6); byte[] decodedBytes Base64.getDecoder().decode(base64Credentials); String credentials new String(decodedBytes, StandardCharsets.UTF_8); // 用户名不能包含冒号密码可以。规范要求按第一个冒号分割 int separatorIndex credentials.indexOf(:); if (separatorIndex 0) { unauthorized(response); return; } String username credentials.substring(0, separatorIndex); String password credentials.substring(separatorIndex 1); boolean usernameOk constantTimeEquals(username, EXPECTED_USERNAME); boolean passwordOk constantTimeEquals(password, EXPECTED_PASSWORD); if (!usernameOk || !passwordOk) { unauthorized(response); return; } } catch (IllegalArgumentException e) { // Base64 解码失败说明请求头格式非法 unauthorized(response); return; } filterChain.doFilter(request, response); } private void unauthorized(HttpServletResponse response) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setHeader(WWW-Authenticate, Basic realm\api\); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\unauthorized\}); } private boolean constantTimeEquals(String a, String b) { return MessageDigest.isEqual( a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); } }这里有两个细节值得说。第一如果用equals比对用户名密码Java 的字符串比较是逐个字符来的遇到第一个不相等的位置就返回 false理论上攻击者可以通过微调输入、观察响应时间的差异来猜测密码。用MessageDigest.isEqual做恒定时间比较能规避这种时序侧信道攻击。第二只对特定路径生效的话可以在 Filter 注册时指定 URL 规则Bean public FilterRegistrationBeanBasicAuthFilter basicAuthFilterRegistration(BasicAuthFilter filter) { FilterRegistrationBeanBasicAuthFilter registration new FilterRegistrationBean(filter); registration.addUrlPatterns(/open/api/*); return registration; }这样写的好处是/open/api/下的接口被过滤器拦下其他所有接口不受影响。2.3 两个方案怎么取舍我的建议很简单新项目、微服务架构、未来可能要扩展权限模型直接上 Spring Security别自己折腾。老项目、只有个别接口需要快速挡一下、不想引入额外依赖手写 Filter 完全够用。另外要特别注意如果你选了 Spring Security就不要在项目里再写自己的 Basic Auth Filter双重校验会导致请求被拦截两次而且一旦两侧密码不一致排查起来会很崩溃。3. 实操记录给一个暴露接口加上 Basic Auth 认证这个部分我完整走一遍流程基于一个叫“模拟项目X”的 Spring Boot 3 服务为例假设它有一个订单查询接口/api/order/list目前裸奔需要快速加上认证。3.1 工程准备与依赖先打开pom.xml确认引入了这两个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency加了spring-boot-starter-security之后服务启动会自动拦截所有请求。这时候直接启动项目访问任何接口都会看到默认的登录页面或者 401因为默认行为是有表单登录的。等我们写完SecurityConfig再接上 Basic Auth。3.2 配置用户与放行路径在application.yml里可以把默认用户的信息通过环境变量注入这样代码仓库里不会出现明文密码spring: security: user: name: ${API_USERNAME:api-user} password: ${API_PASSWORD:change-me-now}但注意这种配置是 Spring Security 的自动配置默认用户一旦我们自定义了UserDetailsService它就会被覆盖。我自己更习惯在配置类里显式定义用户逻辑更清晰。生产环境强烈建议把密码放到配置中心、K8s Secret 或者环境变量里不要写死在代码或配置文件里。放行路径是实际项目中很容易漏掉的一环。一个服务通常伴随着健康检查、接口文档等路径这些路径如果也要求认证会出现一些哭笑不得的问题。比如监控系统定期拉取/actuator/health如果你要求它带上账号密码监控就会一直报服务不可用开发同学想在内网看一眼 Swagger 文档结果也弹登录框体验很差。所以我常配的放行规则是/actuator/health让监控探活不受认证影响。/swagger-ui/**和/v3/api-docs/**内网可见接口文档。/api/public/**如果有一些确实应该公开的接口。其余所有请求都必须认证通过才能放行。3.3 联调测试用 curl 验证认证逻辑配置完成后启动服务我用 curl 做了几组验证这里直接贴上命令和结果。不带任何认证信息访问curl -i http://localhost:8080/api/order/list返回结果HTTP/1.1 401 WWW-Authenticate: Basic realmapi Content-Type: application/json {code:401,message:unauthorized}带正确的用户名密码访问curl -i -u api-user:api-pass-2024 http://localhost:8080/api/order/list返回结果HTTP/1.1 200 Content-Type: application/json {orders:[...]}带错误的密码访问curl -i -u api-user:wrong-password http://localhost:8080/api/order/list返回结果同样是 401。这三个测试覆盖了正常路径、未认证路径、认证失败路径足够证明 Basic Auth 已经生效。3.4 内部服务之间怎么带认证头另一个常见场景是服务 A 调用服务 B 的接口B 开了 Basic AuthA 怎么带凭据这里给两个最常用的写法。RestTemplate 的方式RestTemplate restTemplate new RestTemplate(); HttpHeaders headers new HttpHeaders(); headers.setBasicAuth(api-user, api-pass-2024); HttpEntityString entity new HttpEntity(headers); ResponseEntityString response restTemplate.exchange( http://service-b/api/order/list, HttpMethod.GET, entity, String.class );OpenFeign 的方式稍微特殊一点因为 Feign 默认不会自动带请求头要么在方法签名上显式传RequestHeader(Authorization)要么实现一个RequestInterceptor统一塞进去Bean public RequestInterceptor basicAuthRequestInterceptor() { return template - template.header( Authorization, Basic Base64.getEncoder().encodeToString( api-user:api-pass-2024.getBytes(StandardCharsets.UTF_8) ) ); }要提醒的是如果服务 A 到服务 B 之间的链路走的是 HTTP凭据还是明文在网络上传输公网环境必须走 HTTPS。内网环境如果运维水平够最好也把内部服务之间的 TLS 开起来不过这个优先级可以往后放。3.5 网关和反向代理场景下的坑如果服务前面还有一层 Nginx 或网关还需要确认 Authorization 头有没有被正确转发。Nginx 默认会把请求头透传但如果你加了自定义的 location 规则或者用了某些 WAF 插件可能会把 Authorization 头剥掉。检查方式很简单在服务的访问日志里看有没有收到 Authorization没有的话问题出在网关层。Nginx 里如果想显式透传可以这样location /api/ { proxy_set_header Authorization $http_authorization; proxy_pass http://backend-server; }另外还有一个直接踩过的坑Basic Auth 的凭据如果写到 API 网关的配置里网关到后端服务这段可以不用再带认证但用户到网关这段必须还是 HTTPS否则用户侧的账号密码依然是在裸奔。4. 常见问题排查401 还是 403密码解析失败的坑Basic Auth 整体非常稳但实际联调过程中还是有几类问题反复出现。我把高频问题整理成一个速查表下面逐个展开讲。4.1 401 和 403 的语义到底怎么区分这两个状态码几乎每个后端都遇到过但真的有人分不清。401 是 Unauthorized意思是“我没认出你是谁请先证明身份”403 是 Forbidden意思是“我认出你了但你没有权限做这件事”。Basic Auth 场景下用户名密码错误、请求头缺失都应该是 401。认证通过了但角色权限不够应该是 403。Spring Security 的默认配置里认证失败默认是 401权限不足默认是 403。如果你自己实现 Filter千万不要把两种场景都返回 403因为客户端尤其是浏览器只有收到 401 且带WWW-Authenticate头才会正常弹出登录框收到 403 就只会看到一句冷冰冰的“禁止访问”。状态码含义典型触发条件客户端表现401未认证缺 Authorization 头、密码错误、账号不存在浏览器弹登录框403无权限账号存在但无权访问该接口直接显示禁止访问4.2 密码带冒号和特殊字符的解析规则Basic Auth 原始规范里用户名不能包含冒号但密码可以。因为解码还原后整段字符串是用户名:密码服务端解析时应该按第一个冒号分割后面的内容全部视为密码。如果你遇到用户密码里恰好有冒号比如abc:123只要你的解析逻辑是indexOf(:)就能正确处理。但有一类实现特别容易出问题用split(:)然后取split[1]当密码。如果密码里带了冒号数组长度会超过 2密码就被截断了。我在手写 Filter 的示例里特意用了indexOf而不是split原因就在这里。4.3 中文用户名密码的编码问题HTTP 请求头里的字节流默认是没有强制的字符集约定的但 Basic Auth 的解码过程如果两边编码不一致就会出乱码。通常建议客户端和服务端都统一使用 UTF-8 编码 Base64保证中文用户名也能正常工作。实际操作中遇到的乱码案件绝大多数是两边编码不一致导致的。比如某个老客户端用 GBK 编码用户名服务端用 UTF-8 解码用户名就对不上了。排查方法也很简单把请求头里的 Base64 字符串复制出来分别用 UTF-8、GBK、ISO-8859-1 解码试试哪个能还原出预期用户名问题就定位在哪一侧。4.4 401 响应头缺失导致客户端弹不出登录框有个联调场景困扰了很久浏览器访问受保护接口时明明密码错误了但页面不弹登录框一直在转圈最后超时。后来抓包发现接口返回的是自定义的 JSON 错误但没有带WWW-Authenticate响应头。浏览器识别 Basic Auth 的唯一信号就是这个头没了它浏览器就不知道应该弹登录框。所以只要是自己写 Filter响应 401 时一定记得带上response.setHeader(WWW-Authenticate, Basic realm\api\);realm的值可以理解成“认证域”多个接口共用同一个 realm 时浏览器会复用同一组凭据不会每个接口弹一次体验会好很多。4.5 安全过滤器不生效接口还是裸奔这类问题新手遇到得最多。配置了SecurityFilterChain但访问接口还是直接返回业务数据完全没走认证原因通常有几个第一项目里存在多个SecurityFilterChainBean按优先级匹配后你的配置被另外一个更靠前的配置覆盖了。第二配置类没有加Configuration注解Spring 根本没扫描到它。第三手写 Filter 和 Spring Security 同时存在请求被另一个 Filter 直接放行到业务代码。排查顺序是先看配置类有没有被 Spring 容器加载再看启动日志里安全过滤器的注册顺序最后用带-v参数的 curl 看请求头实际走向。绝大多数问题在前两步就能找到。4.6 踩坑总结API 裸奔的止血流程结合几次线上紧急加认证的经验我总结了一个止血流程遇到“接口已经暴露了”这类告警时可以直接照着做盘点暴露的接口清单确认哪些路径需要认证哪些需要放行。选方案项目已有 Spring Security 就配置httpBasic没有就手写 Filter。加入认证后立刻用 curl 验证 401 和 200 两种路径。通知所有调用方尽快改造增加 Authorization 请求头。观察日志确认非预期调用已经进不来。这个流程的过程重点不是“加强认证”而是“缩短裸奔时间”。认证越早加上风险越小等你有空了再研究更复杂的认证体系也不迟。5. 从止血到长稳暴露 API 认证的进阶思路Basic Auth 能解决“裸奔”的燃眉之急但它不是万能钥匙。这一节想认真聊聊它的安全边界以及后续怎么往更稳的方案演进。5.1 Basic Auth 的安全边界要清楚先列几条容易忽略的隐患凭据没有过期机制。除非运维手动改密码否则用户拿到的账号理论上可以永久使用。Base64 可逆抓包即可还原。虽然加了 HTTPS 后抓不到明文但日志、网关、链路追踪系统里如果落盘了 Authorization 头等于把密码写进了日志。无法做细粒度授权。一个账号只能管“能访问”和“不能访问”做不到“只能读不能写”“只能查 A 模块不能查 B 模块”。暴力破解风险。没有锁定机制攻击者可以无限次尝试密码。配合 IP 频率限制会好很多。所以我说 Basic Auth 是一道门锁不是保险柜。它能挡住未授权的陌生人但保护不了内部人员的恶意操作也防不了暴力破解需要结合其他安全措施一起用。5.2 从 Basic Auth 到更完善的方案如果接口要持续对外提供服务尤其是可能触达公网建议按下面的阶梯逐步演进方案凭据形式防篡改过期机制适合场景实施成本Basic Auth用户名 密码无无内部接口、临时测试低API Key 签名密钥 时间戳 签名有签名校验可设置过期半开放接口、商户对接中OAuth2 Client CredentialsToken有签名可设置过期面向多客户端、多方接入高这里面的逻辑是这样的当你要对接口做更精细的控制时需求通常不是“是否认证”而是“认证之后还能管理什么”。API Key 比 Basic Auth 进步的地方在于你可以按调用方签发不同的密钥出问题时单独吊销某一个而不影响其他调用方。再加上请求签名就能防重放攻击。OAuth2 Client Credentials 是微服务架构里比较标准的服务间认证方式通过授权服务器签发短期 Token配合令牌刷新机制比固定密钥安全得多但引入的成本也不低。回到实际项目我的建议是先分清这个 API 的定位是内部调用、企业间合作还是面向公网。内部调用Basic Auth 加 IP 白名单够用企业间合作上 API Key 加签名面向公网别自己造轮子直接走网关、OAuth2 或更完整的身份认证体系。5.3 如果只能保留一个习惯如果让我从这些经验里提炼一条最想说给后来者听的那就是给接口加认证永远不要嫌早哪怕是测试环境。裸奔一天就有一天的风险敞口。我个人在实际操作中的体会是Basic Auth 这种方案就像灭火器关键时刻比一套复杂的火警系统更直接。日常维护时我习惯把受 Basic Auth 保护的接口全部加一层调用方 IP 的日志记录这样出现异常调用时能快速回溯来源密码也设了定期轮换的脚本每 90 天自动更新一次并同步给调用方。最后一个小技巧服务上线前用专门的扫描工具过一遍全站接口列表凡是返回 401 的确认是预期行为返回 200 的逐个核对是否真的不需要认证。这套流程走下来基本能做到对暴露 API 的访问认证情况心里有数。
阅读完成 · 觉得有帮助?
咨询建站