Spring Security 从零搭到上线,我熬了三个通宵

·赵建国
2025-12-22 03:20
阅读 1393

凌晨两点,公司只剩我和运维老张。咖啡机早就罢工了,我只能靠红牛续命。这已经是本周第三次加班到这个点了——产品那边临时加了个需求:下周一上线新后台系统,必须带完整的权限控制。而我们的后端还是裸奔状态,连个登录都没有。

作为一个在这家电商公司干了三年多的老油条,我对这种“周一上线”的 deadline 早就免疫了。但这次有点不一样:技术栈强制用 Spring Boot,安全模块指定用 Spring Security。说实话,我之前主要搞 Python 后端(Django + FastAPI 那一套玩得飞起),Java 虽然会写,但 Spring Security 这玩意儿文档又臭又长,配置项比 Vim 的 .vimrc 还复杂。

不过,既然要跳槽前最后冲一波 KPI,那就干吧。


为什么不用现成的?因为产品经理说“要定制”

一开始我想偷懒,直接套个 Keycloak 或 Auth0。结果 PM 一句“我们要自己的用户体系,而且权限粒度要到按钮级别”直接把我打回原形。行吧,自己造轮子。

Spring Security 其实挺强大的,但上手门槛高得离谱。光是 WebSecurityConfigurerAdapter 这个类(虽然现在 deprecated 了)就让我翻了半小时源码。更别说 OAuth2、JWT、RBAC 这些概念堆在一起,像极了产品经理画的原型图——看起来很美,实现起来想砸键盘。

不过好在我最近在学 Rust,对“显式优于隐式”这套哲学理解更深了。Spring Security 虽然啰嗦,但每一步都是可控的。只要理清流程,其实没那么可怕。


核心思路:认证 + 授权 + 资源保护

我把整个安全体系拆成三块:

  1. 认证(Authentication):你是谁?用户名密码 or Token?
  2. 授权(Authorization):你能干啥?角色 or 权限?
  3. 资源保护(Resource Protection):哪些接口需要保护?哪些公开?

这三块对应到代码里,就是配置类 + UserDetailsService + 权限注解。

先说数据库设计。我们没用 Spring Data JPA 那套自动建表,而是手动建了三张表:

  • users:存用户基本信息
  • roles:角色表(admin, editor, viewer)
  • user_roles:关联表
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(100) NOT NULL,
    enabled BOOLEAN DEFAULT true
);

CREATE TABLE roles (
    id BIGINT PRIMARY KEY,
    name VARCHAR(20) NOT NULL -- e.g., ROLE_ADMIN
);

CREATE INSERT INTO roles (id, name) VALUES (1, 'ROLE_ADMIN'), (2, 'ROLE_EDITOR');

注:密码字段存的是 BCrypt 加密后的字符串,别学某些人明文存密码,那真的会被开除。


实战:一行配置都不能少

我新建了一个 SecurityConfig.java,继承 WebSecurityCustomizer(新版推荐方式):

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable() // 前后端分离项目,关掉 CSRF
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/actuator/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public JwtAuthFilter jwtAuthFilter() {
        return new JwtAuthFilter();
    }
}

关键点:

  • 关闭 CSRF:因为我们是纯 API 服务,前端用 Axios 发请求,不走表单提交。
  • Session 策略设为 STATELESS:无状态,全靠 JWT。
  • /api/auth/** 公开,其他全部拦截。
  • 自定义 JwtAuthFilter 插在认证过滤器前面。

这里踩了个大坑:一开始忘了 .and() 链式调用,结果权限规则没生效,测试同学直接提了个 P0 Bug:“未登录能删商品?!”。吓得我赶紧回滚,还好没上生产。


用户认证:从数据库查,不是硬编码

很多人 demo 里直接写死用户名密码,比如:

@Bean
public UserDetailsService userDetailsService() {
    UserDetails user = User.withDefaultPasswordEncoder()
        .username("admin")
        .password("123456")
        .roles("ADMIN")
        .build();
    return new InMemoryUserDetailsManager(user);
}

这在生产环境等于自杀。我们当然要接真实数据库。

于是我写了 CustomUserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("User not found");
        }
        return org.springframework.security.core.userdetails.User
            .withUsername(user.getUsername())
            .password(user.getPassword()) // 已经是 BCrypt 加密
            .authorities(user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList()))
            .accountExpired(false)
            .accountLocked(false)
            .credentialsExpired(false)
            .disabled(!user.isEnabled())
            .build();
    }
}

注意:password 字段必须是 BCrypt 格式。生成方法很简单:

String encoded = new BCryptPasswordEncoder().encode("raw_password");

建议在用户注册或重置密码时调用。


资源级权限:@PreAuthorize 是真香

产品要求“编辑只能改自己的文章”,这就要方法级别的权限控制了。

Spring Security 提供了 @PreAuthorize,配合 SpEL 表达式,非常灵活:

@RestController
@RequestMapping("/api/articles")
public class ArticleController {

    @PreAuthorize("@articleService.canEdit(principal.name, #id)")
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteArticle(@PathVariable Long id) {
        articleService.deleteById(id);
        return ResponseEntity.ok().build();
    }
}

对应的 articleService.canEdit() 方法:

public boolean canEdit(String username, Long articleId) {
    Article article = articleRepository.findById(articleId).orElse(null);
    return article != null && article.getAuthor().equals(username);
}

这里用了 @ 引用 Bean,principal.name 拿当前用户名。比硬写 hasRole('EDITOR') 灵活多了。

不过要注意:开启 @PreAuthorize 需要在配置类加 @EnableMethodSecurity(新版)或 @EnableGlobalMethodSecurity(prePostEnabled = true)(旧版)。


性能与运维:别让安全拖垮系统

上线前压测发现,每次请求都要查数据库拿用户权限,QPS 直接掉一半。

解决方案:缓存 UserDetails

我在 CustomUserDetailsService 里加了 Redis 缓存:

@Override
public UserDetails loadUserByUsername(String username) {
    String cacheKey = "user_detail:" + username;
    UserDetails cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) return cached;

    // ...查 DB...
    redisTemplate.opsForValue().set(cacheKey, userDetails, Duration.ofMinutes(30));
    return userDetails;
}

缓存 30 分钟,用户改权限后手动清理即可。线上 QPS 回升到 98%。

另外,日志也加了关键信息:

log.info("User {} accessed {} with roles {}", username, requestURI, authorities);

方便审计和排查问题。运维老张说:“终于不用半夜打电话问我‘为啥某人没权限’了”。


一点综合思考:Python vs Java 安全生态

作为长期混迹 Python 圈的人,不得不说,Spring Security 虽然重,但胜在完备。Django 的 auth 系统简单好用,但要做细粒度权限、多租户、OAuth2 集成,就得自己拼轮子。而 Spring Security 把这些都封装好了,只是学习曲线陡了点。

如果你的团队有 Java 背景,且系统复杂度高,Spring Security 是值得投入的。但如果只是做个内部小工具,Python + FastAPI + PyJWT 可能更快出活。


最后

折腾三天,系统终于上线了。虽然代码还有优化空间(比如权限缓存策略、JWT 刷新机制),但至少扛住了双 11 预热流量。

现在我一边改 Bug,一边刷 Rust 的 async book。毕竟,跳槽简历上不能只有“精通加班”啊。

共勉。

评论 0

最热最新
暂无评论
·赵建国Lv.1
0
影响力
0
文章
0
粉丝