从零搭建认证系统:Spring Security真的比Shiro香吗?

产品经理别看我
2025-12-25 08:39
阅读 1543

大家好,我是小K,两个月前刚加入这家上市公司的技术中台团队。说实话,入职第一天就被拉进一个“紧急重构”项目——把老掉牙的登录模块干掉,换成现代安全架构。产品经理上周五下班前甩过来一句话:“下个月上线,不能出问题。”我盯着那行字,心里一万只草泥马奔腾而过。

不过吐槽归吐槽,活儿还得干。作为一个对前端动画情有独钟(可惜现在天天写后端)的码农,我其实一直对安全这块有点发怵。以前在小公司用过 Apache Shiro,觉得挺顺手;但新东家是正经上市公司,合规、审计、多租户……一套套下来,光听运维大哥念叨“等保三级”我就头皮发麻。

所以这次领导拍板:上 Spring Security。理由很直接——生态完善、社区活跃、和 Spring Boot 天然集成。但问题是,市面上关于它的教程要么太浅(Hello World 级别),要么太深(源码级剖析),中间缺个“打工人速成指南”。正好借这个机会,把自己踩过的坑、做的技术选型对比、以及为什么最终放弃其他方案的原因写下来,也算给后来人铺点路。

顺便说一句,最近在刷招聘网站,发现不少岗位要求“熟悉 Spring Security”,甚至有些区块链项目也招 Java 安全工程师(虽然我怀疑他们是不是只是想蹭热点)。可见这玩意儿,真不是可有可无的玩具,而是求职市场的硬通货。


为啥不用 Shiro?也不是 Python 的 FastAPI?

先说结论:不是 Shiro 不好,而是它在复杂企业场景下显得“力不从心”。

我们内部做过一轮技术选型评估,主要对比了三个方向:

方案 优点 缺点 适用场景
Spring Security 与 Spring 生态无缝集成,OAuth2/JWT 支持完善,权限模型灵活,社区强大 学习曲线陡峭,配置稍显复杂 中大型企业、微服务架构、需对接第三方认证(如微信、钉钉)
Apache Shiro API 简洁,上手快,轻量 社区活跃度下降,微服务支持弱,OAuth2 需额外扩展 中小型单体应用、内部工具系统
Python (FastAPI + Authlib) 开发效率高,异步友好,适合快速原型 公司技术栈以 Java 为主,运维体系不兼容,缺乏成熟的企业级审计能力 AI/数据分析平台、实验性项目

看到最后那个 Python 选项,可能有人会问:你们公司不是搞区块链相关业务吗?怎么不用 Python 写智能合约配套服务?

哈哈,这里得澄清一下。我们确实有区块链实验室,但他们主攻底层共识算法和跨链协议,后端服务还是 Java 扛大旗。至于 Python,更多用于数据清洗和链上分析脚本——安全认证这种核心模块,谁敢轻易换语言?运维大哥第一个提刀上门。

而且,FastAPI 虽然香,但在我们这种强合规环境下,连日志格式都要符合 ISO 标准,更别说权限审计了。Spring Security 的 @PreAuthorize 和方法级安全,配合 AOP 做操作留痕,简直是为审计而生。

所以,尽管我内心还惦记着用 Python 写个 Flask 小玩具,现实还是把我按回了 Java 的工位上。


快速搭建:三步走战略

既然定了 Spring Security,那就开干。我们的目标很明确:一周内跑通账号密码登录 + JWT 令牌 + 接口权限控制。别笑,这在我们这儿已经算“敏捷”了——上次有个需求改个按钮颜色,PR 走了三天审批。

第一步:基础依赖与结构设计

首先,在 pom.xml 加上核心依赖(注意版本对齐!):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</td>
</tr>
<tr>
<td><code>spring-security-oauth2-resource-server</code></td>
<td>用于解析 JWT 令牌</td>
</tr>
<tr>
<td><code>jjwt-api</code> / <code>jjwt-impl</code></td>
<td>生成和解析 JWT(别用旧版,坑多)</td>
</tr>
</tbody>
</table>

数据库设计上,我们沿用了经典的 RBAC 模型:
- <code>sys_user</code>:用户表(含 username, password, enabled)
- <code>sys_role</code>:角色表
- <code>sys_permission</code>:权限表(如 <code>user:read</code>, <code>order:create</code>)
- 中间表关联用户-角色、角色-权限

> 💡 **血泪教训**:千万别把权限字符串直接存用户表!后期加角色、改权限会疯掉。我们之前有个老系统就这么干的,每次产品说“加个导出功能”,DBA 就要批量 update 上万条记录……

### 第二步:自定义 UserDetailsService

Spring Security 默认用内存用户,显然不能上生产。我们实现了自己的 `UserDetailsService`:

```java
@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }
        
        // 从 DB 加载权限列表,转成 GrantedAuthority
        List<GrantedAuthority> authorities = buildAuthorities(user.getRoles());
        
        return org.springframework.security.core.userdetails.User
                .builder()
                .username(user.getUsername())
                .password(user.getPassword()) // 注意:必须是 BCrypt 加密后的
                .authorities(authorities)
                .accountExpired(!user.isEnabled())
                .build();
    }

    private List<GrantedAuthority> buildAuthorities(List<Role> roles) {
        Set<String> permissions = new HashSet<>();
        for (Role role : roles) {
            permissions.addAll(role.getPermissions().stream()
                .map(Permission::getCode)
                .collect(Collectors.toSet()));
        }
        return permissions.stream()
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());
    }
}

这里有个细节:密码必须用 BCrypt 加密。别偷懒用 MD5,现在连 CTF 比赛都不考 MD5 碰撞了好吗!

第三步:JWT 认证过滤器

Spring Security 默认用 Session,但我们是前后端分离 + 微服务,必须上无状态 JWT。于是自定义一个过滤器:

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain chain) throws ServletException, IOException {
        
        String token = extractTokenFromHeader(request);
        if (token != null && jwtUtil.validateToken(token)) {
            String username = jwtUtil.getUsernameFromToken(token);
            UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
            
            UsernamePasswordAuthenticationToken auth = 
                new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());
            auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        chain.doFilter(request, response);
    }

    private String extractTokenFromHeader(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

然后在 SecurityConfig 里注册它:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/auth/login").permitAll()
                .requestMatchers("/actuator/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

⚠️ 线上事故预警:千万别忘了 .csrf().disable()!我们测试环境一开始没关,前端 POST 登录直接 403,还以为是 Nginx 配置错了,查了两小时……


权限控制:从 URL 到方法

搞定认证只是第一步,真正的难点在授权。

我们采用 双层权限控制

  1. URL 级:粗粒度拦截(如 /api/admin/** 只允许 ADMIN 角色)
  2. 方法级:细粒度控制(如 @PreAuthorize("hasPermission('order','delete')")

URL 配置很简单:

.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")

但方法级才是王道。比如删除订单接口:

@DeleteMapping("/orders/{id}")
@PreAuthorize("@permissionService.hasPermission(authentication, 'order', 'delete')")
public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
    orderService.delete(id);
    return ResponseEntity.noContent().build();
}

这里的 @permissionService 是我们自定义的 Bean,会根据当前用户、资源类型、操作类型动态判断。好处是:权限逻辑和业务代码解耦,产品哪天说“运营也能删订单”,改一行配置就行,不用动代码。


性能与监控:别让安全拖垮系统

安全模块最容易成为性能瓶颈。我们做了几件事:

  1. 缓存 UserDetails:用户登录信息缓存 30 分钟,避免每次请求都查 DB。
  2. JWT 本地校验:公钥放在 configmap,不走远程验证。
  3. 关键操作埋点:登录失败、权限拒绝等事件上报到 ELK,方便审计。

运维同事还专门配了 Grafana 面板,监控:

  • 每分钟认证请求数
  • JWT 过期率
  • 权限拒绝次数(异常飙升可能意味着攻击)

上周就靠这个发现了个脚本小子在暴力破解测试账号,IP 直接被防火墙 ban 了。


写在最后:安全不是功能,是底线

折腾两周后,新认证系统终于上线。虽然过程中被产品经理催了八百遍,被测试提了三十多个“边界 case”,但看到登录成功那一刻返回的 200 OK,心里还是有点小骄傲。

回头想想,Spring Security 确实比 Shiro “重”,但它的严谨和扩展性,在企业级场景下是值得的。就像我们 CTO 说的:“你可以没有 fancy 的功能,但不能没有安全的底线。”

顺便,如果你也在看新工作,不妨把 Spring Security 当成跳槽加分项。现在连做区块链钱包后台的公司都在招懂 OAuth2 的 Java 工程师——毕竟,再炫酷的 DApp,登录不上也是白搭。

好了,今天就水到这里。我去改下一个需求了,听说又要对接钉钉扫码登录……(叹气)

P.S. 如果你也在用 Spring Security,欢迎留言交流踩坑经验。要是能顺便安利点前端动画库,那就更好了 😄

评论 0

最热最新
暂无评论
产品经理别看我Lv.1
0
影响力
0
文章
0
粉丝