Spring Security实战:从零搭建一个安全认证系统

深巷里的服务器
2025-06-16 19:58
阅读 519

引言:为什么要重新设计权限认证系统?

引言:为什么要重新设计权限认证系统?

去年,我在一家做在线教育平台的公司担任后端架构师。当时我们的用户系统还处于快速迭代阶段,早期为了赶进度,账号模块用了最简单的 Session 登录机制,甚至没有引入像 OAuth2 或 JWT 这样的标准方案。

随着业务的发展,我们开始接入第三方应用、开发 API 接口供外部调用,也遇到了越来越多的安全问题——例如:

  • 用户登录信息存储方式不安全,容易被中间人攻击;
  • 第三方平台调用时缺乏统一的令牌体系;
  • 权限粒度控制得不够细,管理混乱;
  • 没有清晰的接口访问控制策略,系统面临潜在风险。

于是我们决定重构整个认证授权模块,目标是建立一套既能满足当前需求,又能支持未来扩展的安全认证系统。而最终选择的技术方案就是基于 Spring Security + OAuth2 + JWT 的组合。这篇文章会以这次项目为背景,结合我在这个过程中踩过的坑和学到的经验,和大家一起过一遍如何从零搭建一个 Spring Security 基础的安全认证系统。


一、项目背景与实际挑战

数据库设计模型-1

一、项目背景与实际挑战

系统现状

原来的用户登录逻辑非常简单粗暴:

// 登录接口伪代码
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
    if (userService.authenticate(request.username, request.password)) {
        String token = UUID.randomUUID().toString();
        redisService.set("session:" + token, request.username);
        return ok(token);
    }
    throw new AuthException("用户名或密码错误");
}

然后每个需要鉴权的接口都通过自定义注解去拦截 token:

@InterceptorType(Authenticated.class)
public class AuthInterceptor implements HandlerInterceptor {...}

虽然这种实现方式在初期还能凑合用,但到了中后期已经很难维护了,权限模型也不清晰,而且根本谈不上可扩展性。

新系统的期望目标

我们希望通过重构带来以下几个提升:

  1. 统一的身份认证入口
  2. 标准化的 Token 生成和验证机制(使用 JWT)
  3. 灵活的权限管理策略(包括 URL 级别的权限控制)
  4. 对接第三方服务的能力(OAuth2 Client / Resource Server 支持)
  5. 高性能且具备容灾能力的身份中心

二、技术选型与架构设计思路

二、技术选型与架构设计思路

综合我们团队对 Java 技术栈的熟悉程度,以及目前 Spring 生态圈的发展情况,我们最终选择了:

  • Spring Boot + Spring Security 作为核心框架;
  • JWT 用于 Token 的生成与校验;
  • OAuth2 协议 提供开放 API 的接入能力;
  • 数据库选用 PostgreSQL 存储用户、角色、权限等配置数据。

整体架构分为三个层级:

1. 授权服务器(Auth Server)

职责包括:

  • 处理用户登录、注销请求;
  • 颁发 JWT Token;
  • 提供 OAuth2 Client 认证流程;
  • 管理权限分组(Role-Based Access Control, RBAC);

部署上采用双节点加 Nginx 负载均衡,Token 使用 Redis 缓存保存吊销状态(Revoked Tokens),并配合自动刷新机制。

2. 安全网关(Security Gateway)

这部分不是本篇重点,不过值得一提的是我们在 Zuul/Feign 上做了统一鉴权处理,确保所有服务间的调用都要带上有效的 Token,并在网关层完成 URL 级别的权限拦截,减少下游服务的负担。

3. 各个微服务(Resource Server)

这些服务不再自己处理登录逻辑,而是依赖 Auth Server 提供的 Token 做身份识别和权限判断。


三、快速上手:从零搭建 Spring Security 基础认证系统

接下来我会一步步带大家搭一个最基础的 Spring Security 安全认证原型,让你了解它是怎么工作的,同时也会讲清楚一些关键点和注意事项。

示例源码托管于 GitHub:spring-security-demo(请替换成自己的链接)

Step 1: 初始化 Spring Boot 工程

使用 start.spring.io 创建一个 Spring Boot 项目,添加以下依赖:

  • Spring Web
  • Spring Security
  • Spring Data JPA
  • PostgreSQL Driver(或者其他你习惯用的 DB)

Step 2: 用户表结构设计

这里我采用了经典的 RBAC 模型:

用户表 users

字段名 类型 描述
id bigserial 主键
username varchar 用户名
password varchar 加密后的密码
enabled boolean 是否启用

角色表 roles

字段名 类型 描述
id bigserial 主键
name varchar 角色名称,如“ROLE_ADMIN”

用户-角色关系表 user_roles

user_id role_id

这样设计可以灵活地给用户赋予多个角色,也可以根据角色来做权限分级。

Step 3: 实现基本的登录逻辑

我们先让 Spring Security 能跑起来,然后逐步增强功能。下面是 SecurityConfig 的一个初始版本:

@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(withDefaults())
            .logout(withDefaults());
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService(UserRepository userRepository) {
        return username -> {
            User user = userRepository.findByUsername(username);
            if (user == null) {
                throw new UsernameNotFoundException("User not found");
            }
            return new org.springframework.security.core.userdetails.User(
                user.getUsername(),
                user.getPassword(),
                user.isEnabled(),
                true,
                true,
                true,
                AuthorityUtils.createAuthorityList("ROLE_USER"));
        };
    }

    // 密码加密器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

这个例子展示了最基本的认证过程:用户登录之后,默认分配了一个 ROLE_USER 权限,并允许访问非 /public/** 开头的路径。

这时候你可以运行项目,访问任何页面都会跳转到默认的登录页。当然我们现在还没有注册逻辑,所以需要手动插入一条用户记录进数据库:

INSERT INTO users (username, password, enabled) VALUES ('alice', '$2a$10$IH9T9tqZcBQ7JhRj8zXKCeO2VvL6yS5nFzPdWzYfXeZbU7l5gkGGO', true);

INSERT INTO roles(name) VALUES('ROLE_ADMIN');
INSERT INTO user_roles(user_id, role_id) VALUES(1, 1);

其中 $2a$... 是用 BCryptPasswordEncoder.encode() 方法生成的测试密码。


四、加入 JWT 和无状态认证

随着 RESTful API 的普及,我们希望实现一种无状态的身份认证机制,也就是不依赖 Session,而是通过 Token 完成身份验证。

什么是 JWT?

JSON Web Token(JWT)是一种轻量级的身份凭证格式,它由三部分组成:

  • Header:说明签名算法等;
  • Payload(Data):包含用户信息,比如 ID、姓名、权限;
  • Signature:用于防止篡改的签名字段。

使用 JWT 的优势包括:

  • 无状态:适用于分布式部署;
  • 跨域友好:比 Cookie 更适合前后端分离项目;
  • 可扩展性强:可以通过 Claims 添加额外信息;
  • 性能较高:不需要每次都查库验证 Token;

JWT 登录流程示意图

客户端
   ↓
POST /login → 返回 JWT
   ↓
在后续请求 Header 中带上 Authorization: Bearer xxxxx
   ↓
资源服务器验证 Token 合法性,提取权限信息
   ↓
判断是否允许访问该接口

现在我们就来改造一下刚才的系统,支持 JWT 登录流程。

Step 1:创建 JWT 工具类

我们可以基于开源的 jjwt 库实现 Token 的生成和解析:

<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>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
</dependency>

缓存策略对比-2

@Component
public class JwtUtils {

    private final String secret = "this_is_a_very_secret_key";

    public String generateToken(String username, Collection<? extends GrantedAuthority> authorities) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("authorities", authorities.stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toList()));
        
        return Jwts.builder()
            .setClaims(claims)
            .setSubject(username)
            .setExpiration(new Date(System.currentTimeMillis() + 3600000))
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
    }

    public String parseToken(String token) {
        return Jwts.parser()
            .setSigningKey(secret)
            .parseClaimsJws(token)
            .getBody()
            .getSubject();
    }

    public Collection<SimpleGrantedAuthority> getAuthoritiesFromToken(String token) {
        List<String> authorities = Jwts.parser()
            .setSigningKey(secret)
            .parseClaimsJws(token)
            .getBody()
            .get("authorities", List.class);

        return authorities.stream()
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());
    }
}

Step 2:自定义过滤器链

我们需要替换掉默认的登录流程,改为返回 JWT Token。

创建一个继承 OncePerRequestFilter 的类,在每次请求的时候检查是否存在 Token:

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtils jwtUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        String token = getTokenFromHeader(request);

        if (token != null && jwtUtils.validateToken(token)) {
            String username = jwtUtils.parseToken(token);
            Collection<SimpleGrantedAuthority> authorities = jwtUtils.getAuthoritiesFromToken(token);
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                username, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String getTokenFromHeader(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

并在 SecurityConfig 中将其添加进过滤器链:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf().disable();
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    
    http.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/auth/login").permitAll()
        .anyRequest().authenticated());

    return http.build();
}

Step 3:创建登录接口返回 Token

再写一个 Controller,用来处理登录请求并返回 Token:

@RestController
@RequestMapping("/auth")
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtUtils jwtUtils;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
        Authentication authenticate = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword())
        );

        String token = jwtUtils.generateToken(authenticate.getName(), authenticate.getAuthorities());

        return ResponseEntity.ok(token);
    }
}

别忘了注册 AuthenticationManager

@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
    return http.getSharedObject(AuthenticationManagerBuilder.class)
        .build();
}

五、权限控制:从 Role 到 URL 级别细粒度限制

有了 JWT 之后,权限控制就可以通过 Token 里的 authorities 来实现了。不过很多时候我们还需要更细粒度的权限控制,比如只有特定角色才能访问某个 API。

这就要用到 Spring Security 的方法级别和接口级别的权限控制。

1. 接口级别控制(URL)

在 SecurityConfig 中,我们之前只是写了 .anyRequest().authenticated(),其实可以用 Ant Path 匹配更精确地控制路径:

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers("/api/student/**").hasAnyRole("STUDENT", "TEACHER")
    .requestMatchers("/api/teacher/**").hasRole("TEACHER")
    .anyRequest().authenticated());

注意,这里有个小技巧:.hasRole() 会自动加上前缀 ROLE_,所以我们数据库里角色命名也要统一以 ROLE_ 开头,比如 “ROLE_ADMIN”。

2. 方法级别权限控制(AOP)

有时候我们要控制某一个方法能否执行,而不是某个路由。比如:

@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public void deleteUser(Long userId) {
    // 只有 ADMIN 或者 自己才能删除用户
}

要让这个生效,必须在启动类上加上:

@EnableGlobalMethodSecurity(prePostEnabled = true)

这个特性是基于 AOP 实现的,非常强大,推荐在 Service 层使用。


六、遇到的问题与解决经验分享

Q:用户修改密码后,已签发的 Token 如何失效?

这是 JWT 的一个常见痛点:因为 Token 是无状态的,签发出去就不能主动收回。解决方案有几种:

  1. 黑名单机制:将失效的 Token 存入 Redis 缓存,验证时先查缓存;
  2. 短生命周期 + Refresh Token:Token 生效时间设短一点(如 5 分钟),搭配 Refresh Token 延长有效期;
  3. 黑名单 + 缓存 Key TTL 设置:避免无限期保存黑名单,设置和 Token 相同的过期时间即可;

我们最终选择了第一种 + 设置 Key 的过期时间,这样既保障安全性又节省资源。


Q:如何做到不同服务之间 Token 可共用?

如果多个微服务共享同一个授权中心(Auth Server),只要他们使用相同的密钥(Secret),就能互相验证 Token 的合法性。因此建议在生产环境将 Secret 配置集中化,比如使用 Vault 或者 Spring Cloud Config 统一管理。


七、总结:这套架构带来的收益与思考

从最初的一个简单登录逻辑,到如今具备权限控制、OAuth2、JWT 等能力的完整认证系统,这个过程并不轻松,但也让我们收获颇丰:

  • 提升了安全性:统一的 Token 标准,更强的身份校验机制;
  • 增强了扩展能力:RBAC 模式 + 接口细粒度权限,方便未来扩展新业务;
  • 降低了耦合度:各个服务只需要关心认证结果,无需参与认证过程本身;
  • 提高了运维效率:通过黑白名单、监控日志、集中注销等功能,提升了排查问题效率。

最后:几点经验建议给后来者

  1. 不要重复造轮子,能用 Spring Security 就别自己写拦截器;
  2. 权限模型要尽早设计清楚,否则后期改起来代价很高;
  3. JWT 不等于万能钥匙,要考虑 Token 失效、续期等问题;
  4. 多做单元测试 & 集成测试,尤其是权限边界测试;
  5. 关注社区变化,Spring Security 从 5.x 到现在的 Boot 3.x,不少组件都有变化,升级需谨慎。

如果你也在做一个需要认证授权功能的 Spring Boot 项目,不妨试试上面这套方案,亲测可行,而且性能和扩展性都不错。欢迎留言交流你们的落地经验和遇到的难题 😊


附录资源推荐:

如有需要,我会持续更新配套示例项目源码及文档,欢迎 Star 关注!

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝