Spring Security 学习笔记:从零搭建认证授权体系

最近接手了一个企业内部管理系统,安全模块基于 Spring Security 构建。虽然之前用过不少认证框架,但深入 Spring Security 的配置细节时还是踩了不少坑。于是决定从头搭建一个学习项目,把核心概念逐个吃透。这篇文章就是我的学习笔记。

项目地址github.com/ov-vo/spring-security-learning

技术栈:Java 21 + Spring Boot 3.2.4 + Spring Security 6.x + Thymeleaf + Maven

一、Spring Security 是什么

简单来说,Spring Security 是 Spring 生态中的安全框架,负责处理两大核心问题:

  • 认证(Authentication)——你是谁?验证用户身份
  • 授权(Authorization)——你能做什么?控制用户权限

它通过一系列 过滤器(Filter) 拦截 HTTP 请求,在请求到达 Controller 之前完成身份验证和权限检查。这些过滤器的集合被称为 SecurityFilterChain——这是理解 Spring Security 最重要的概念。

二、项目初始化

创建一个 Spring Boot 项目,在 pom.xml 中引入以下核心依赖:

<!-- 核心安全依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- Web 支持 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 模板引擎(用于自定义登录页) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<!-- 开发热重载 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <scope>runtime</scope>
</dependency>

项目结构保持简洁:

spring-security-learning/
├── pom.xml
├── src/main/java/com/example/security/
│   ├── SecurityLearningApplication.java    # 启动类
│   ├── config/
│   │   └── SecurityConfig.java            # ⭐ 安全配置核心
│   └── controller/
│       ├── DemoController.java            # 演示不同权限等级的接口
│       └── LoginController.java           # 登录页控制器
└── src/main/resources/
    ├── application.properties
    └── templates/
        └── login.html                      # 自定义登录页面

三、核心配置:SecurityConfig

这是整个项目的心脏。Spring Security 6.x 使用基于 Lambda 的 DSL 来配置安全规则,废弃了旧版的 WebSecurityConfigurerAdapter

3.1 SecurityFilterChain —— 安全过滤器链

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 1. 按 URL 规则授权
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()    // 公开访问
                .requestMatchers("/admin/**").hasRole("ADMIN") // 仅管理员
                .anyRequest().authenticated()                 // 其余需要登录
            )
            // 2. 表单登录
            .formLogin(form -> form
                .loginPage("/login")      // 自定义登录页 URL
                .permitAll()              // 登录页允许所有人访问
            )
            // 3. HTTP Basic 认证(方便 curl 测试)
            .httpBasic(Customizer.withDefaults())
            // 4. 学习阶段关闭 CSRF(生产环境请务必开启!)
            .csrf(csrf -> csrf.disable());

        return http.build();
    }
}

让我逐条解释这段配置:

🔓 permitAll()

不需要任何认证即可访问。适用于首页、静态资源、注册/登录页。

🔐 authenticated()

只要登录了就能访问,不检查角色。适用于需要登录但无特殊权限要求的页面。

👑 hasRole("ADMIN")

必须登录且持有 ADMIN 角色。Spring Security 会自动在角色名前加 ROLE_ 前缀,所以 hasRole("ADMIN") 实际匹配的是 ROLE_ADMIN

此外还有 hasAuthority()(精确匹配权限名,不加前缀)、hasAnyRole()(满足任一角色即可)等变体。

3.2 PasswordEncoder —— 密码编码器

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

BCrypt 是目前主流的密码哈希算法,自带盐值(salt),能有效抵御彩虹表攻击。永远不要把明文密码存入数据库或写在代码里——即使是学习项目,也建议从第一天就养成使用 BCrypt 的习惯。

3.3 UserDetailsService —— 用户数据源

这个 Bean 定义了"用户从哪里来"。学习阶段我们用内存用户:

@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
    InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();

    // 普通用户
    manager.createUser(User.withUsername("user")
            .password(encoder.encode("password"))
            .roles("USER")
            .build());

    // 管理员
    manager.createUser(User.withUsername("admin")
            .password(encoder.encode("password"))
            .roles("USER", "ADMIN")
            .build());

    return manager;
}

这里展示了两个关键设计:

  • encoder.encode("password") —— 存储前先对密码做 BCrypt 哈希
  • admin 用户同时拥有 USERADMIN 角色,这意味着 admin 可以访问所有 user 能访问的资源

在实际项目中,你会把这个 InMemoryUserDetailsManager 替换成基于数据库的 JdbcUserDetailsManager,或者自己实现 UserDetailsService 接口来对接任何用户存储(数据库、LDAP、外部 API 等)。

四、控制器:验证权限分层

为了直观感受权限控制的效果,我编写了三个层级的接口:

公开接口 —— 无需登录

@RestController
public class DemoController {

    @GetMapping("/public/hello")
    public String publicHello() {
        return "Hello, 这是一个公开接口!任何人都可以访问。";
    }
}

用户接口 —— 需要登录但不限角色

@GetMapping("/user/hello")
public Map<String, Object> userHello() {
    return Map.of(
        "message", "你好,认证用户!",
        "username", getCurrentUsername(),
        "roles", getCurrentRoles()
    );
}

@GetMapping("/user/me")
public Map<String, Object> currentUser() {
    Authentication auth = SecurityContextHolder
        .getContext().getAuthentication();
    return Map.of(
        "username", auth.getName(),
        "authorities", auth.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .toList(),
        "authenticated", auth.isAuthenticated()
    );
}

这里展示了如何在代码中获取当前用户信息——通过 SecurityContextHolder.getContext().getAuthentication(),这是 Spring Security 的安全上下文,存储着当前请求的用户认证信息。整个调用链是这样工作的:

SecurityContextHolder           ← 全局持有者(基于 ThreadLocal)
  └── SecurityContext           ← 当前请求的安全上下文
        └── Authentication      ← 认证对象
              ├── getName()          → "user"
              ├── getAuthorities()   → [ROLE_USER]
              └── isAuthenticated()  → true

管理员接口 —— 需要 ADMIN 角色

@GetMapping("/admin/hello")
public Map<String, Object> adminHello() {
    return Map.of(
        "message", "欢迎,管理员!",
        "username", getCurrentUsername()
    );
}

@GetMapping("/admin/system-info")
public Map<String, Object> systemInfo() {
    return Map.of(
        "os.name", System.getProperty("os.name"),
        "java.version", System.getProperty("java.version"),
        "availableProcessors", Runtime.getRuntime().availableProcessors()
    );
}

/admin/system-info 是一个很好的实战示例——暴露敏感的服务器信息。在生产环境中,这类接口必须严格限制访问权限。

五、自定义登录页

Spring Security 自带一个简陋的默认登录页。但对于任何正式项目来说,自定义登录页是必选。我的登录页通过 Thymeleaf 模板渲染:

@Controller
public class LoginController {

    @GetMapping("/login")
    public String loginPage() {
        return "login";  // 对应 templates/login.html
    }
}
<!-- templates/login.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>登录 - Spring Security Learning</title>
    <style>
        body { font-family: system-ui; display: flex;
               justify-content: center; align-items: center;
               min-height: 100vh; background: #f5f5f7; }
        .login-box { background: white; padding: 40px;
                     border-radius: 16px; box-shadow: 0 4px 24px rgba(0,0,0,.06);
                     width: 360px; }
        input { width: 100%; padding: 10px 14px; margin: 8px 0;
                border: 1px solid #d2d2d7; border-radius: 8px; font-size: 15px; }
        button { width: 100%; padding: 12px; background: #0071e3;
                 color: white; border: none; border-radius: 8px;
                 font-size: 16px; font-weight: 600; cursor: pointer; }
    </style>
</head>
<body>
    <div class="login-box">
        <h2 style="text-align:center">🔐 系统登录</h2>

        <!-- 测试账号提示 -->
        <div style="background:#f0f7ff; padding:12px; border-radius:8px;
                    margin:16px 0; font-size:13px; color:#555;">
            <strong>测试账号</strong><br>
            普通用户:user / password<br>
            管理员:admin / password
        </div>

        <form th:action="@{/login}" method="post">
            <input type="text" name="username"
                   placeholder="用户名" required>
            <input type="password" name="password"
                   placeholder="密码" required>
            <button type="submit">登 录</button>
        </form>
    </div>
</body>
</html>

这里有两个关键约定:

  1. 表单的 action 必须指向 @{/login},且 method 必须为 POST
  2. 用户名和密码的输入框 name 属性必须是 usernamepassword(这是 Spring Security 的默认字段名)

六、实际测试

启动项目后,你可以用 curl 快速验证各种场景:

# 1. 访问公开接口 —— 无需认证 ✅
curl http://localhost:8080/public/hello
# → Hello, 这是一个公开接口!

# 2. 访问用户接口 —— 需要认证(不带凭据)❌
curl http://localhost:8080/user/hello
# → 返回 401 / 重定向到登录页

# 3. 用 Basic Auth 访问用户接口 ✅
curl -u user:password http://localhost:8080/user/hello
# → {"message":"你好,认证用户!","username":"user","roles":"ROLE_USER"}

# 4. 普通用户访问管理接口 ❌ 403 Forbidden
curl -u user:password http://localhost:8080/admin/system-info
# → 返回 403

# 5. 管理员访问管理接口 ✅
curl -u admin:password http://localhost:8080/admin/system-info
# → {"os.name":"Mac OS X","java.version":"21.0.2",...}

第 4 个测试尤其值得注意:user 已经通过了认证,但因为缺少 ADMIN 角色,返回的是 403 Forbidden 而非 401 Unauthorized。这完美展示了"认证"和"授权"是两个独立的概念。

七、踩坑记录

坑 1:CSRF 导致 POST 请求 403

如果你的登录表单提交后一直返回 403,很可能是 CSRF 保护在捣乱。Spring Security 默认开启 CSRF 保护,要求所有 POST 请求携带 CSRF token。学习阶段可以暂时关闭,但上线前务必重新开启

// 学习时关闭
http.csrf(csrf -> csrf.disable());

// 上线时恢复(删除这行即可,默认就是开启的)
// 并在 Thymeleaf 表单中加上:
// <input type="hidden" th:name="${_csrf.parameterName}"
//        th:value="${_csrf.token}" />

坑 2:Spring Security 6.x 的 API 变化

如果你参考的是旧版教程,可能会看到 WebSecurityConfigurerAdapter 的用法。这个类在 Spring Security 5.7+ 被标记为弃用,在 6.x 中已完全移除。新版本统一使用 基于 Bean 的配置:定义 SecurityFilterChain Bean 并在其中使用 Lambda 表达式配置规则。

坑 3:角色名自动加 ROLE_ 前缀

使用 .hasRole("ADMIN") 时,Spring Security 自动在前面加上 ROLE_,变成 ROLE_ADMIN。如果你在 .roles() 里写成 "ROLE_ADMIN",最终会变成 ROLE_ROLE_ADMIN——这个坑已经坑了无数人。

八、学习路线图

这个项目只是 Spring Security 的入门第一步。基于这个基础,后续可以逐步深入:

  1. 数据库用户存储 —— 用 Spring Data JPA + MySQL/PostgreSQL 替代内存用户,实现 UserDetailsService 接口
  2. 注册与邮箱验证 —— 实现完整的注册流程,加入邮箱激活
  3. JWT 无状态认证 —— 前后端分离场景下,用 JWT 替代 Session
  4. OAuth2 / OIDC —— 接入 GitHub / Google 第三方登录
  5. 方法级安全 —— 使用 @PreAuthorize@PostAuthorize 做细粒度权限控制
  6. 自定义认证提供者 —— 实现 AuthenticationProvider 对接企业已有的认证系统
  7. 安全测试 —— 用 @WithMockUserMockMvc 编写安全测试

总结

Spring Security 的配置看起来繁琐,但核心思想其实非常清晰:

过滤器链(FilterChain)拦截请求 → 认证管理器验证身份 → 投票器(Voter)决定是否有权限 → 放行或拒绝。

理解了这个流程,再回头看配置代码,每一行都变得有据可循。如果你也在学习 Spring Security,建议不要试图一上来就配置一个"完整的企业级安全方案"——那只会让你迷失在无数配置项中。像我这样从最小可运行项目开始,逐步添加功能,学习效率会高得多。

完整代码已开源在 GitHub,欢迎 clone 下来自己跑一跑,把每个接口用 curl 测一遍,理解会更加深刻。