一个iOS老兵跨界搞Spring Security的心路历程

乐观锁玩家
2026-07-14 13:54
阅读 732

说实话,写这篇博客的时候我心情挺复杂的。

做了6年iOS开发,从Objective-C时代一路摸爬滚打到Swift全面普及,见证了苹果生态里大大小小的技术变迁。在公司待了3年多,业务代码写得飞起,但总觉得自己的技术栈有点单一。去年下半年开始琢磨着换个环境,面试了几家才发现,现在稍微好点的公司都要求你懂点后端、懂点云原生,最好还能搞搞K8s。得,那就学呗。

刚好前阵子公司接了个内部小产品,需要搞一套安全认证系统。后端那帮哥们儿忙得脚朝天,我就自告奋勇揽了这活儿——反正我也想练练手,顺便给简历加点料。折腾了大概两周,总算把Spring Security的基础认证给跑通了,踩了不少坑,也攒了些心得,干脆写篇文章记录一下。

为啥要自己搞认证

先说下背景。我们那个产品是个面向B端客户的数据分析平台,类似于Dify那种AI应用开发平台的定位,但更偏向企业内部使用。产品经理(我又要吐槽了,我们那个PM真的是需求变更狂魔)上周突然说,客户对数据安全特别敏感,必须加上完善的权限认证体系,什么多租户隔离啊、RBAC权限模型啊、Token刷新机制啊,巴拉巴拉提了一堆。

后端leader老王听完直接摆手:"这活儿至少得两个人干两周。"我心想这不正好是个学习机会嘛,就举手说我来试试。老王看我的眼神就像看一个外星人——"你一个写iOS的,能搞定Spring Security?"嘿,这话我可不爱听了,虽然我是iOS出身,但好歹也玩过K8s,对云原生那套东西还算熟悉,后端也不是完全没碰过。

从零开始搭架子

说干就干。我先去翻了翻Spring Security的官方文档,好家伙,那文档写得跟天书似的,上来就是一堆Filter链的概念,看得我脑仁疼。后来换了个思路,直接找了几个开源项目参考,结合官方文档里的核心概念,才算理清楚了整体架构。

Spring Security的核心其实就是一堆过滤器(Filter)组成的责任链。请求进来之后,会依次经过这些Filter,每个Filter负责处理特定的安全逻辑。理解了这个,后面的配置就好办了。

先看看我的项目依赖,用的是Spring Boot 3.x版本:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

这里有个小插曲。一开始我用的Spring Boot 2.7,配SecurityConfig的时候还是继承WebSecurityConfigurerAdapter,结果升级到3.x之后这个类直接被干掉了,API全变了。当时对着屏幕愣了十分钟,心想这Spring团队是真狠啊,说废弃就废弃。后来老老实实看了迁移指南,改用组件化的方式来配置。

SecurityConfig核心配置

这是整个认证系统的心脏,我改了好几版才稳定下来:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthFilter;

    @Autowired
    private AuthenticationProvider authenticationProvider;

    @Autowired
    private AuthenticationEntryPoint unauthorizedHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/api/v1/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .authenticationProvider(authenticationProvider)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(exception -> 
                exception.authenticationEntryPoint(unauthorizedHandler));

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }
}

几个关键点说一下:

第一,CSRF我直接关了。为啥?因为我们做的是前后端分离的RESTful API,前端用JWT Token来认证,不存在CSRF攻击的场景。当然,如果是传统的Session+Cookie模式,CSRF防护是必须开的。

第二,Session策略设置成STATELESS。这个很重要,意味着服务端不保存任何Session信息,完全靠Token来维持认证状态。好处是天然支持水平扩展,坏处就是Token一旦签发,在过期之前没法主动让它失效(除非引入黑名单机制)。

第三,路径权限的配置。/api/v1/auth/**是登录注册相关的接口,必须放行;/api/v1/public/**是一些公开数据接口;其他所有接口都需要认证。这个配置我调了好几次,因为一开始忘了放行健康检查接口,导致K8s的liveness probe一直失败,Pod反复重启,运维小哥差点来找我拼命。

JWT Token的生成和校验

Token这块我用了jjwt这个库,算是Java生态里比较成熟的JWT实现了。先看看Token的生成逻辑:

@Component
public class JwtService {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.expiration}")
    private long expiration;

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toList()));

        return Jwts.builder()
            .setClaims(claims)
            .setSubject(userDetails.getUsername())
            .setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSignInKey(), SignatureAlgorithm.HS256)
            .compact();
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(getSignInKey())
            .build()
            .parseClaimsJws(token)
            .getBody();
    }

    private SecretKey getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

这里有个坑我必须提一下。一开始我把secretKey直接写在代码里,后来代码review的时候被老王一顿输出:"你是想让黑客直接拿你的key去伪造Token吗?"好吧,老老实实把密钥放到了配置中心,通过环境变量注入。

另外关于Token过期时间,我们产品讨论了很久。最终方案是Access Token 30分钟过期,Refresh Token 7天过期。用户无感刷新Token的逻辑放在了前端,后端只需要提供一个刷新接口就行。

认证过滤器的实现

JWT认证过滤器是插在UsernamePasswordAuthenticationFilter之前的,每次请求都会先经过它:

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {

        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String username;

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        jwt = authHeader.substring(7);
        try {
            username = jwtService.extractUsername(jwt);
            if (username != null && 
                SecurityContextHolder.getContext().getAuthentication() == null) {

                UserDetails userDetails = 
                    this.userDetailsService.loadUserByUsername(username);

                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken = 
                        new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (Exception e) {
            logger.error("JWT认证失败: {}", e.getMessage());
        }

        filterChain.doFilter(request, response);
    }
}

这段代码看着简单,但我当时调试的时候真的想砸电脑。问题出在哪呢?Token校验一直失败,报Signature verification failed。我对着代码看了半天,Token生成和校验用的都是同一个secretKey啊,怎么就验签失败了呢?

后来排查了两个小时才发现,是因为配置中心里的secretKey在注入的时候被Base64编码了一次,而我在代码里又做了一次Base64解码。相当于编解码了两次,key对不上当然验签失败了。这种bug真的让人抓狂,因为不会有任何明确的错误提示,就是一个冷冰冰的验签失败。

数据库设计

说到数据库,简单提一下我们的用户表和权限表设计。因为产品要支持多租户,所以用户表里加了tenant_id字段:

CREATE TABLE `users` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(50) NOT NULL,
    `password` VARCHAR(255) NOT NULL,
    `email` VARCHAR(100) NOT NULL,
    `tenant_id` BIGINT NOT NULL,
    `status` TINYINT NOT NULL DEFAULT 1,
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_username_tenant` (`username`, `tenant_id`),
    UNIQUE KEY `uk_email_tenant` (`email`, `tenant_id`),
    KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `roles` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(50) NOT NULL,
    `description` VARCHAR(200),
    `tenant_id` BIGINT NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_name_tenant` (`name`, `tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `user_roles` (
    `user_id` BIGINT NOT NULL,
    `role_id` BIGINT NOT NULL,
    PRIMARY KEY (`user_id`, `role_id`),
    FOREIGN KEY (`user_id`) REFERENCES `users` (`id`),
    FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `permissions` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(100) NOT NULL,
    `description` VARCHAR(200),
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `role_permissions` (
    `role_id` BIGINT NOT NULL,
    `permission_id` BIGINT NOT NULL,
    PRIMARY KEY (`role_id`, `permission_id`),
    FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`),
    FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有个设计上的取舍。一开始我想把permissions也做成租户级别的,每个租户可以自定义权限。但后来跟产品讨论后发现,大部分B端产品其实不需要这么灵活,权限模型基本是固定的,差异主要在角色上。所以最终permissions是全局的,roles是租户级别的。

另外密码存储用的是BCrypt,cost factor设成了12。这个值不是越大越好,需要在安全性和性能之间取个平衡。我在测试环境跑了一下,cost 12的情况下,加密一次大概需要200多毫秒,对于登录接口来说完全可以接受。

登录接口的实现

最后看看登录接口长什么样:

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {

    private final AuthenticationService authService;

    @PostMapping("/login")
    public ResponseEntity<AuthenticationResponse> login(
            @Valid @RequestBody LoginRequest request) {
        return ResponseEntity.ok(authService.login(request));
    }

    @PostMapping("/register")
    public ResponseEntity<AuthenticationResponse> register(
            @Valid @RequestBody RegisterRequest request) {
        return ResponseEntity.ok(authService.register(request));
    }

    @PostMapping("/refresh")
    public ResponseEntity<AuthenticationResponse> refresh(
            @Valid @RequestBody RefreshTokenRequest request) {
        return ResponseEntity.ok(authService.refreshToken(request));
    }
}
@Service
@RequiredArgsConstructor
public class AuthenticationService {

    private final UserRepository userRepository;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;
    private final PasswordEncoder passwordEncoder;

    public AuthenticationResponse login(LoginRequest request) {
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getUsername(), request.getPassword()));

        var user = userRepository.findByUsername(request.getUsername())
            .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));

        var token = jwtService.generateToken(user);
        var refreshToken = jwtService.generateRefreshToken(user);

        return AuthenticationResponse.builder()
            .accessToken(token)
            .refreshToken(refreshToken)
            .expiresIn(1800)
            .build();
    }

    public AuthenticationResponse register(RegisterRequest request) {
        var user = User.builder()
            .username(request.getUsername())
            .password(passwordEncoder.encode(request.getPassword()))
            .email(request.getEmail())
            .tenantId(request.getTenantId())
            .build();

        userRepository.save(user);

        var token = jwtService.generateToken(user);
        var refreshToken = jwtService.generateRefreshToken(user);

        return AuthenticationResponse.builder()
            .accessToken(token)
            .refreshToken(refreshToken)
            .expiresIn(1800)
            .build();
    }
}

上线后的一些思考

这套认证系统上线之后跑了大概一个月,整体还算稳定。但过程中也暴露了一些问题,我记录一下自己的开发心得:

性能方面,每次请求都要查数据库验证用户信息,在高并发场景下肯定扛不住。后来我加了Redis缓存,把用户信息和权限信息缓存起来,设置5分钟的过期时间。这样大部分请求就不需要查库了,QPS直接翻了好几倍。

安全方面,我们后来还加了登录失败次数限制。同一个IP连续失败5次就锁定15分钟,同一个账号连续失败10次就锁定30分钟。这个逻辑我写了一个自定义的Filter,放在JWT过滤器之前。

运维方面,因为用的是无状态认证,所以扩容缩容特别方便,直接改K8s的replicas就行。但有个问题就是Token黑名单,如果用户被禁用或者修改了密码,已经签发的Token在过期之前还是有效的。为了解决这个问题,我们引入了Redis来维护一个Token黑名单,用户状态变更的时候把对应的Token加进去。

说回来,作为一个写了6年iOS的老兵,这次跨界搞Spring Security确实让我学到了很多。以前总觉得后端就是写写CRUD,真正上手了才发现里面的门道一点都不比客户端少。而且现在回头看,客户端和后端的很多设计理念其实是相通的,比如依赖注入、面向接口编程、责任链模式等等,只是在不同技术栈里的实现方式不一样而已。

最近也在研究Dify的源码,发现他们做RAG(Retrieval-Augmented Generation)的时候,权限控制这块也是用的类似方案。不同的租户、不同的知识库,权限隔离做得很细。这给了我不少启发,等后面有空了,打算把我们产品的权限模型再优化一下,支持到知识库级别的细粒度控制。

好了,这篇文章就写到这里。如果你也是从客户端转后端的朋友,希望我的这些踩坑经验能帮到你少走点弯路。Spring Security这东西,入门确实有点劝退,但一旦理解了它的设计思路,后面配置起来其实还是挺顺手的。

不说了,PM又催需求了,我得去搬砖了。下次有机会再聊聊Spring Security的高级玩法,比如OAuth2、SAML这些,先让我缓缓。

评论 0

最热最新
暂无评论
乐观锁玩家Lv.1
0
影响力
0
文章
0
粉丝