Skip to content
第 64 / 250 章后端⏱ 10 分钟阅读

第 64 章:Spring Security 入门

学习目标

  • 理解认证(Authentication)与授权(Authorization)的本质
  • 掌握 Spring Security 6 的核心过滤器链
  • 学会自定义登录、密码加密、权限校验

一、为什么需要安全框架?

Spring Security 是 Spring 全家桶里最强大的安全框架,处理:

  • 认证(Authentication):登录、Session、Token
  • 授权(Authorization):谁能访问什么资源
  • 防护:CSRF、XSS、Session 固定攻击、Clickjacking

二、核心架构:过滤器链

核心机制:每个请求要穿过一串过滤器,过滤器链是 Spring Security 的核心。

三、快速集成

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
java
// 启动类加 @EnableWebSecurity
@SpringBootApplication
@EnableWebSecurity
public class TaskflowApplication { }
yaml
# 默认配置下,所有接口都需要认证
# 浏览器访问 → 自动跳到默认登录页(账号 user,密码控制台随机生成)

四、自定义安全配置(Spring Security 6 新写法)

java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService userDetailsService;
    private final JwtAuthenticationFilter jwtFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            // ① 禁用 CSRF(前后端分离 + JWT 方案不需要)
            .csrf(AbstractHttpConfigurer::disable)

            // ② 配置 CORS
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))

            // ③ 禁用 Session(无状态)
            .sessionManagement(s -> s
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // ④ 配置路径权限
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()      // 登录注册放行
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")    // 需要 ADMIN 角色
                .anyRequest().authenticated()                      // 其他需要登录
            )

            // ⑤ 配置自定义认证
            .authenticationProvider(authenticationProvider())

            // ⑥ 加 JWT 过滤器(在 UsernamePasswordAuthenticationFilter 之前)
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)

            // ⑦ 自定义登录失败 / 权限不足的响应
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint((req, resp, e) -> {
                    resp.setStatus(401);
                    resp.setContentType("application/json;charset=utf-8");
                    resp.getWriter().write(JSON.toJSONString(
                            Result.fail(401, "未登录或登录已过期")));
                })
                .accessDeniedHandler((req, resp, e) -> {
                    resp.setStatus(403);
                    resp.getWriter().write(JSON.toJSONString(
                            Result.fail(403, "权限不足")));
                })
            )

            .build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

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

    private CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration cfg = new CorsConfiguration();
        cfg.addAllowedOriginPattern("*");
        cfg.addAllowedHeader("*");
        cfg.addAllowedMethod("*");
        cfg.setAllowCredentials(true);
        cfg.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", cfg);
        return source;
    }
}

五、UserDetailsService:加载用户

java
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserMapper userMapper;
    private final RoleMapper roleMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // ① 查用户
        User user = userMapper.selectByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        // ② 查角色和权限
        List<Role> roles = roleMapper.selectByUserId(user.getId());
        List<GrantedAuthority> authorities = roles.stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r.getCode()))
                .collect(Collectors.toList());

        // ③ 查细粒度权限(可选)
        List<String> perms = permissionMapper.selectByUserId(user.getId());
        perms.forEach(p -> authorities.add(new SimpleGrantedAuthority(p)));

        // ④ 返回 Spring Security 用户对象
        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .authorities(authorities)
                .disabled(user.getStatus() != 1)
                .accountLocked(user.getStatus() == 2)
                .build();
    }
}

六、密码加密

java
@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();     // ① 推荐
    }
}

@Service
public class UserService {

    private final PasswordEncoder encoder;

    public void register(UserDTO dto) {
        User user = new User();
        user.setUsername(dto.getUsername());
        user.setPassword(encoder.encode(dto.getPassword()));    // ② 加密
        userMapper.insert(user);
    }

    public boolean login(String username, String password) {
        User user = userMapper.selectByUsername(username);
        return encoder.matches(password, user.getPassword());   // ③ 校验
    }
}

BCrypt 原理

java
// BCrypt 每次加密结果都不同(自带 salt)
encoder.encode("123456");   // $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
encoder.encode("123456");   // $2a$10$WQu9wYAgPvuK3i5Yj8yM.eIjZAgcfl7p92ldGxad68LJZdL17lhWy  (不一样)

// 长度 60 字符,前 22 位是 salt + cost

// ❌ 错误:用 MD5 + 固定 salt(不安全,容易被彩虹表攻击)
String md5 = DigestUtils.md5Hex(password + "salt");

// ❌ 错误:明文存储
user.setPassword(password);

// ❌ 错误:用可解密的加密
String encrypted = AES.encrypt(password);   // 能解密就有泄露风险
算法安全性推荐度
MD5 / SHA1❌ 已破解
SHA256 + salt⚠️ 算力攻击⚠️
BCrypt✅ 自带 salt,可调 cost推荐
Argon2✅ 最强✅ 但 Spring Boot 默认不支持
SCrypt✅ 强⚠️

七、JWT 集成(前后端分离主流方案)

xml
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>
java
@Component
public class JwtUtil {

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

    @Value("${jwt.expire}")
    private Long expire;     // 秒

    // ① 生成 token
    public String generate(Long userId, String username, List<String> roles) {
        return Jwts.builder()
                .subject(String.valueOf(userId))
                .claim("username", username)
                .claim("roles", roles)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expire * 1000))
                .signWith(getKey(), Jwts.SIG.HS256)
                .compact();
    }

    // ② 解析 token
    public Claims parse(String token) {
        return Jwts.parser()
                .verifyWith(getKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    private SecretKey getKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }
}
yaml
jwt:
  secret: ${JWT_SECRET}    # 至少 256 位(32 字节)
  expire: 7200            # 2 小时

JWT 过滤器

java
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse resp,
                                    FilterChain chain)
            throws ServletException, IOException {

        // ① 提取 token
        String token = extractToken(req);
        if (token == null) {
            chain.doFilter(req, resp);                    // 放行(后面的过滤器决定)
            return;
        }

        try {
            // ② 解析 token
            Claims claims = jwtUtil.parse(token);
            String username = claims.get("username", String.class);

            // ③ 设置认证上下文
            if (username != null && SecurityContextHolder.getContext()
                    .getAuthentication() == null) {

                UserDetails userDetails =
                        userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken auth =
                        new UsernamePasswordAuthenticationToken(
                                userDetails, null, userDetails.getAuthorities());
                auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        } catch (ExpiredJwtException e) {
            log.warn("Token 已过期");
            SecurityContextHolder.clearContext();
        } catch (JwtException e) {
            log.warn("Token 非法: {}", e.getMessage());
            SecurityContextHolder.clearContext();
        }

        chain.doFilter(req, resp);
    }

    private String extractToken(HttpServletRequest req) {
        String bearer = req.getHeader("Authorization");
        if (bearer != null && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

登录接口

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

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService;

    @PostMapping("/login")
    public Result<LoginVO> login(@RequestBody LoginDTO dto) {
        // ① 认证(自动调用 UserDetailsService + PasswordEncoder)
        Authentication auth = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        dto.getUsername(), dto.getPassword()));

        // ② 生成 token
        UserDetails user = (UserDetails) auth.getPrincipal();
        String token = jwtUtil.generate(
                extractUserId(user), user.getUsername(),
                user.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList());

        return Result.ok(new LoginVO(token, user.getUsername()));
    }
}

八、方法级权限控制

java
@Service
public class UserService {

    // ① 拥有 ROLE_ADMIN 角色才能调用
    @PreAuthorize("hasRole('ADMIN')")
    public void deleteUser(Long id) {
        userMapper.deleteById(id);
    }

    // ② 拥有 user:create 权限才能调用
    @PreAuthorize("hasAuthority('user:create')")
    public void createUser(UserDTO dto) {
        userMapper.insert(toUser(dto));
    }

    // ③ 同时满足两个条件
    @PreAuthorize("hasRole('ADMIN') and #dto.userId == authentication.principal.id")
    public void updateProfile(@RequestBody UserDTO dto) {
        // ...
    }

    // ④ 表达式语法(SpEL)
    @PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')")
    @PostAuthorize("returnObject.owner == authentication.name")   // 返回后校验
    public Order getOrder(Long id) {
        return orderMapper.selectById(id);
    }
}

启动类加 @EnableMethodSecurity

java
@EnableMethodSecurity(prePostEnabled = true)
public class TaskflowApplication { }

九、CSRF 与 CORS

CSRF(默认开启,前后端分离要关)

java
// ❌ 前后端分离 + JWT:禁用 CSRF
http.csrf(AbstractHttpConfigurer::disable);

// ✅ 传统 Session 方案:保留 CSRF
http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalseHandler()));

CORS

java
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration cfg = new CorsConfiguration();
    cfg.addAllowedOriginPattern("https://*.taskflow.com");
    cfg.addAllowedHeader("*");
    cfg.addAllowedMethod("*");
    cfg.setAllowCredentials(true);
    cfg.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", cfg);
    return source;
}

十、常见安全配置

java
// ① 同一账号最多一个会话
http.sessionManagement(s -> s
    .maximumSessions(1)
    .maxSessionsPreventsLogin(true));

// ② Session 过期处理
http.sessionManagement(s -> s
    .invalidSessionStrategy((req, resp, ex) ->
            resp.sendError(401, "Session 已过期")));

// ③ 限制登录失败次数
http.formLogin(form -> form
    .failureHandler((req, resp, ex) -> {
        // 记录失败次数
        if (failureCount > 5) lockAccount(username);
    }));

// ④ 登录成功处理
http.formLogin(form -> form
    .successHandler((req, resp, auth) -> {
        resp.setContentType("application/json;charset=utf-8");
        resp.getWriter().write(JSON.toJSONString(Result.ok("登录成功")));
    }));

十一、本章小结

要点关键
核心过滤器链(Filter Chain)
认证流程用户名/密码 → UserDetailsService → PasswordEncoder → AuthenticationManager
密码加密BCrypt(自带 salt,不可逆)
JWT无状态,前后端分离主流
路径权限authorizeHttpRequests().requestMatchers(...).hasRole(...)
方法权限@PreAuthorize("hasAuthority('user:create')")
CSRF前后端分离 + JWT 时关闭
入口拦截authenticationEntryPoint 返回 401
权限不足accessDeniedHandler 返回 403

动手练习

练习 1:基础题

实现用户登录接口:用户名密码验证 + BCrypt 校验 + 返回 JWT token。

练习 2:进阶题

实现 JWT 过滤器:从 Header 提取 token → 解析 → 设置 SecurityContext → 放行到 Controller。

练习 3:思考题

你的系统有三种角色:超管、普通管理员、普通用户。如何设计权限模型?菜单权限和数据权限怎么实现?


下一章第 65 章:JWT 深入

本站基于 VitePress 构建 · 由 Codebook 团队维护