Java Spring Security 实战详解:从登录认证到 JWT 权限控制

简介

Spring Security 是 Spring 体系里专门处理安全问题的框架。

它主要解决三件事:

text 复制代码
登录认证:账号密码对不对
访问授权:这个账号能不能访问这个接口
安全防护:CSRF、Session 固定攻击、密码加密等

一句话概括:

text 复制代码
Spring Security 不是只做登录页,它是一套围绕请求过滤器展开的安全体系。

在 Spring Boot 项目里,引入 spring-boot-starter-security 后,所有接口默认都需要登录。这就是常说的"默认安全"。

实际项目里,最常见的用法有两类:

  • 后台管理系统:表单登录、Session、角色权限
  • 前后端分离接口:JSON 登录、JWT、无状态认证

这篇文章按 Spring Boot 3 和 Spring Security 6 的写法展开,不使用旧版 WebSecurityConfigurerAdapter

认证和授权

Spring Security 最核心的两个词是 AuthenticationAuthorization

Authentication

Authentication 表示认证。

它回答的是:

text 复制代码
当前请求是谁发起的。

常见认证方式有:

  • 用户名密码登录
  • 手机号验证码登录
  • JWT Token
  • OAuth2 登录
  • LDAP 登录

账号密码登录时,流程大概是:

text 复制代码
用户提交账号密码
  |
  v
Spring Security 校验账号是否存在
  |
  v
校验密码是否匹配
  |
  v
认证成功后生成 Authentication
  |
  v
放入 SecurityContext

认证成功后,业务代码就能拿到当前登录用户。

Authorization

Authorization 表示授权。

它回答的是:

text 复制代码
当前用户能不能做这件事。

比如:

text 复制代码
GET /api/profile       登录用户可以访问
GET /api/admin/users   ADMIN 角色可以访问
DELETE /api/users/1    user:delete 权限可以访问

认证解决"是谁",授权解决"能干什么"。

请求是怎么经过 Spring Security 的

Spring Security 的核心不是 Controller,也不是拦截器,而是 Servlet Filter

一次请求进入应用后,大致会走这个链路:

text 复制代码
HTTP 请求
  |
  v
Servlet FilterChain
  |
  v
FilterChainProxy
  |
  v
SecurityFilterChain
  |
  v
认证过滤器
  |
  v
授权过滤器
  |
  v
Controller

几个核心对象可以这样理解:

对象 作用
SecurityFilterChain 配置哪些请求放行、哪些请求需要登录、哪些请求需要权限
SecurityContextHolder 保存当前线程里的登录信息
SecurityContext 安全上下文,里面放着 Authentication
Authentication 当前认证对象,包含用户名、权限、是否认证成功
UserDetailsService 根据用户名加载用户信息
UserDetails Spring Security 认识的用户对象
GrantedAuthority 权限标识,比如 ROLE_ADMINuser:delete
PasswordEncoder 密码加密和密码匹配

Maven 依赖

下面用 Spring Boot 3 做示例。

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.6</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.6</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

如果只做 Session 登录演示,jjwt 这三个依赖可以先不加。

第一个 Demo:内存用户登录

先看最小可运行版本。

这个版本不连接数据库,直接在内存里放两个用户:

用户名 密码 角色 权限
admin 123456 ADMIN user:listuser:delete
tom 123456 USER user:list

配置类:

java 复制代码
package com.example.securitydemo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/public").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(Customizer.withDefaults())
                .logout(Customizer.withDefaults());

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails admin = User.withUsername("admin")
                .password(passwordEncoder.encode("123456"))
                .roles("ADMIN")
                .authorities("user:list", "user:delete", "ROLE_ADMIN")
                .build();

        UserDetails tom = User.withUsername("tom")
                .password(passwordEncoder.encode("123456"))
                .roles("USER")
                .authorities("user:list", "ROLE_USER")
                .build();

        return new InMemoryUserDetailsManager(admin, tom);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

测试接口:

java 复制代码
package com.example.securitydemo.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class DemoController {

    @GetMapping("/public")
    public Map<String, Object> publicApi() {
        return Map.of("message", "公开接口,不需要登录");
    }

    @GetMapping("/profile")
    public Map<String, Object> profile(Authentication authentication) {
        return Map.of(
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }

    @GetMapping("/admin/users")
    public Map<String, Object> adminUsers() {
        return Map.of("message", "只有 ADMIN 角色可以访问");
    }

    @DeleteMapping("/users/{id}")
    @PreAuthorize("hasAuthority('user:delete')")
    public Map<String, Object> deleteUser(@PathVariable Long id) {
        return Map.of("message", "删除成功", "id", id);
    }
}

访问效果:

text 复制代码
GET /api/public
不登录也能访问

GET /api/profile
未登录会跳转到默认登录页

GET /api/admin/users
admin 可以访问,tom 不能访问

DELETE /api/users/1
拥有 user:delete 权限才可以访问

这个 demo 已经包含了 Spring Security 的基本骨架:

text 复制代码
SecurityFilterChain 配访问规则
UserDetailsService 提供用户
PasswordEncoder 处理密码
Authentication 表示当前登录用户
@PreAuthorize 做方法级权限控制

Role 和 Authority 的区别

RoleAuthority 很容易混。

Spring Security 底层真正判断的是 Authority

Role 只是带固定前缀的特殊 Authority

java 复制代码
.roles("ADMIN")

等价于添加:

text 复制代码
ROLE_ADMIN

所以:

java 复制代码
.requestMatchers("/api/admin/**").hasRole("ADMIN")

实际检查的是:

text 复制代码
ROLE_ADMIN

而下面这种写法检查的是普通权限:

java 复制代码
@PreAuthorize("hasAuthority('user:delete')")

它不会自动补 ROLE_ 前缀。

常见约定:

text 复制代码
角色:ROLE_ADMIN、ROLE_USER
权限:user:list、user:add、user:delete

密码为什么不能明文存储

数据库里不能存:

text 复制代码
123456

应该存哈希后的结果:

text 复制代码
{bcrypt}$2a$10$......

推荐使用:

java 复制代码
@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

它返回的是 DelegatingPasswordEncoder

密码格式通常长这样:

text 复制代码
{bcrypt}加密后的密码

前面的 {bcrypt} 表示使用哪种算法校验。后面算法升级时,可以兼容老密码。

生成密码可以写一个简单测试:

java 复制代码
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String rawPassword = "123456";
String encodedPassword = encoder.encode(rawPassword);

System.out.println(encodedPassword);
System.out.println(encoder.matches(rawPassword, encodedPassword));

输出类似:

text 复制代码
{bcrypt}$2a$10$wbc0z12Yy0J7LQXMtWpm2uRld5qFf2qa7xM3k5NfR3Fkl.PzT4Bcq
true

每次生成的密文不一样,这是正常现象。BCrypt 会加入随机盐值。

数据库用户 Demo

内存用户适合学习,不适合真实项目。

真实项目通常从数据库加载用户、角色、权限。

下面用 JPA 和 H2 演示。换成 MySQL 时,实体和 Repository 基本不用变。

application.yml

yaml 复制代码
spring:
  datasource:
    url: jdbc:h2:mem:security_demo
    driver-class-name: org.h2.Driver
    username: sa
    password:

  h2:
    console:
      enabled: true

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

app:
  jwt:
    secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
    expire-minutes: 120

用户实体

java 复制代码
package com.example.securitydemo.entity;

import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "sys_user")
public class SysUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private Boolean enabled = true;

    @ElementCollection(fetch = FetchType.EAGER)
    private Set<String> roles = new HashSet<>();

    @ElementCollection(fetch = FetchType.EAGER)
    private Set<String> permissions = new HashSet<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public Boolean getEnabled() {
        return enabled;
    }

    public void setEnabled(Boolean enabled) {
        this.enabled = enabled;
    }

    public Set<String> getRoles() {
        return roles;
    }

    public void setRoles(Set<String> roles) {
        this.roles = roles;
    }

    public Set<String> getPermissions() {
        return permissions;
    }

    public void setPermissions(Set<String> permissions) {
        this.permissions = permissions;
    }
}

Repository

java 复制代码
package com.example.securitydemo.repository;

import com.example.securitydemo.entity.SysUser;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface SysUserRepository extends JpaRepository<SysUser, Long> {

    Optional<SysUser> findByUsername(String username);
}

初始化两条用户数据

java 复制代码
package com.example.securitydemo.config;

import com.example.securitydemo.entity.SysUser;
import com.example.securitydemo.repository.SysUserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.Set;

@Configuration
public class DataInitConfig {

    @Bean
    public CommandLineRunner initUsers(SysUserRepository userRepository, PasswordEncoder passwordEncoder) {
        return args -> {
            if (userRepository.findByUsername("admin").isEmpty()) {
                SysUser admin = new SysUser();
                admin.setUsername("admin");
                admin.setPassword(passwordEncoder.encode("123456"));
                admin.setRoles(Set.of("ADMIN"));
                admin.setPermissions(Set.of("user:list", "user:add", "user:delete"));
                userRepository.save(admin);
            }

            if (userRepository.findByUsername("tom").isEmpty()) {
                SysUser tom = new SysUser();
                tom.setUsername("tom");
                tom.setPassword(passwordEncoder.encode("123456"));
                tom.setRoles(Set.of("USER"));
                tom.setPermissions(Set.of("user:list"));
                userRepository.save(tom);
            }
        };
    }
}

实现 UserDetailsService

Spring Security 不直接认识业务里的 SysUser

它认识的是 UserDetails

所以需要把数据库用户转换成 Spring Security 的用户对象。

java 复制代码
package com.example.securitydemo.security;

import com.example.securitydemo.entity.SysUser;
import com.example.securitydemo.repository.SysUserRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class DbUserDetailsService implements UserDetailsService {

    private final SysUserRepository userRepository;

    public DbUserDetailsService(SysUserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));

        List<GrantedAuthority> authorities = new ArrayList<>();

        for (String role : user.getRoles()) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
        }

        for (String permission : user.getPermissions()) {
            authorities.add(new SimpleGrantedAuthority(permission));
        }

        return User.withUsername(user.getUsername())
                .password(user.getPassword())
                .disabled(!user.getEnabled())
                .authorities(authorities)
                .build();
    }
}

这里的关键点:

text 复制代码
数据库角色 ADMIN -> ROLE_ADMIN
数据库权限 user:delete -> user:delete

hasRole("ADMIN") 检查 ROLE_ADMIN

hasAuthority("user:delete") 检查 user:delete

前后端分离为什么常用 JWT

传统表单登录依赖 Session。

流程是:

text 复制代码
登录成功
  |
  v
服务端保存 Session
  |
  v
浏览器保存 JSESSIONID
  |
  v
后续请求带 Cookie

前后端分离、移动端、小程序、微服务场景里,经常选择 JWT。

流程变成:

text 复制代码
登录成功
  |
  v
服务端签发 JWT
  |
  v
前端保存 Token
  |
  v
后续请求放到 Authorization 请求头
  |
  v
服务端解析 Token 并恢复登录状态

请求头格式:

text 复制代码
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

JWT 的特点:

  • 服务端不需要保存登录 Session
  • Token 被篡改后签名校验会失败
  • Token 过期后需要重新登录或刷新
  • 已签发 Token 在过期前通常仍然有效,强制下线需要额外做黑名单或版本号

JWT 工具类

java 复制代码
package com.example.securitydemo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.List;

@Component
public class JwtService {

    private final SecretKey secretKey;
    private final long expireMinutes;

    public JwtService(
            @Value("${app.jwt.secret}") String secret,
            @Value("${app.jwt.expire-minutes}") long expireMinutes
    ) {
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expireMinutes = expireMinutes;
    }

    public String generateToken(UserDetails userDetails) {
        Instant now = Instant.now();
        List<String> authorities = userDetails.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        return Jwts.builder()
                .subject(userDetails.getUsername())
                .claim("authorities", authorities)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusSeconds(expireMinutes * 60)))
                .signWith(secretKey)
                .compact();
    }

    public String getUsername(String token) {
        return parseClaims(token).getSubject();
    }

    public boolean isValid(String token, UserDetails userDetails) {
        String username = getUsername(token);
        Date expiration = parseClaims(token).getExpiration();
        return username.equals(userDetails.getUsername()) && expiration.after(new Date());
    }

    private Claims parseClaims(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

app.jwt.secret 不能太短。

HS256 至少需要 256 bit 密钥,换算下来至少 32 字节。生产环境不要把密钥硬编码在代码里,通常放到环境变量、配置中心或密钥管理系统。

登录接口

先定义请求和响应对象。

java 复制代码
package com.example.securitydemo.dto;

public record LoginRequest(String username, String password) {
}
java 复制代码
package com.example.securitydemo.dto;

public record LoginResponse(String token, String tokenType) {
}

登录 Controller:

java 复制代码
package com.example.securitydemo.controller;

import com.example.securitydemo.dto.LoginRequest;
import com.example.securitydemo.dto.LoginResponse;
import com.example.securitydemo.security.JwtService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;

    public AuthController(AuthenticationManager authenticationManager, JwtService jwtService) {
        this.authenticationManager = authenticationManager;
        this.jwtService = jwtService;
    }

    @PostMapping("/login")
    public LoginResponse login(@RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.username(), request.password())
        );

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String token = jwtService.generateToken(userDetails);

        return new LoginResponse(token, "Bearer");
    }
}

这里没有手写密码校验。

这行代码会触发 Spring Security 的认证流程:

java 复制代码
authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(username, password)
);

底层会调用 UserDetailsService 查用户,再用 PasswordEncoder 校验密码。

JWT 认证过滤器

登录接口只负责签发 Token。

后续请求还需要一个过滤器,从请求头里取 Token,校验后放入 SecurityContextHolder

java 复制代码
package com.example.securitydemo.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

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

        try {
            if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                String username = jwtService.getUsername(token);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                if (jwtService.isValid(token, userDetails)) {
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(
                                    userDetails,
                                    null,
                                    userDetails.getAuthorities()
                            );

                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        } catch (Exception ex) {
            SecurityContextHolder.clearContext();
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");

        if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
            return authorization.substring(7);
        }

        return null;
    }
}

OncePerRequestFilter 表示一次请求只执行一次。

核心动作只有三个:

text 复制代码
取出 Authorization 请求头
校验 JWT 并加载用户
把认证信息放入 SecurityContextHolder

JWT 版 SecurityConfig

前后端分离接口通常不使用默认登录页,也不使用 Session。

配置如下:

java 复制代码
package com.example.securitydemo.config;

import com.example.securitydemo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/login", "/api/public").permitAll()
                        .requestMatchers("/h2-console/**").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .headers(headers -> headers
                        .frameOptions(frameOptions -> frameOptions.sameOrigin())
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

这里有几个重点:

配置 说明
csrf(AbstractHttpConfigurer::disable) JWT API 不靠 Cookie 自动带身份,通常关闭 CSRF
SessionCreationPolicy.STATELESS 不创建服务端 Session
requestMatchers("/api/auth/login").permitAll() 登录接口必须放行
addFilterBefore(...) JWT 过滤器放在用户名密码过滤器前面
@EnableMethodSecurity 开启 @PreAuthorize

如果是传统表单页面,并且认证状态依赖 Cookie 和 Session,就不要随手关闭 CSRF。

统一 JSON 错误响应

前后端分离项目里,未登录和无权限不适合返回 HTML。

可以自定义两个处理器。

未登录处理:

java 复制代码
package com.example.securitydemo.security;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException
    ) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("""
                {"code":401,"message":"未登录或登录已过期"}
                """);
    }
}

无权限处理:

java 复制代码
package com.example.securitydemo.security;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JsonAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException
    ) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("""
                {"code":403,"message":"权限不足"}
                """);
    }
}

加到配置里:

java 复制代码
.exceptionHandling(exception -> exception
        .authenticationEntryPoint(jsonAuthenticationEntryPoint)
        .accessDeniedHandler(jsonAccessDeniedHandler)
)

完整构造器示例:

java 复制代码
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JsonAuthenticationEntryPoint jsonAuthenticationEntryPoint;
private final JsonAccessDeniedHandler jsonAccessDeniedHandler;

public SecurityConfig(
        JwtAuthenticationFilter jwtAuthenticationFilter,
        JsonAuthenticationEntryPoint jsonAuthenticationEntryPoint,
        JsonAccessDeniedHandler jsonAccessDeniedHandler
) {
    this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    this.jsonAuthenticationEntryPoint = jsonAuthenticationEntryPoint;
    this.jsonAccessDeniedHandler = jsonAccessDeniedHandler;
}

测试 JWT 接口

登录:

shell 复制代码
curl -X POST http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"123456"}'

返回:

json 复制代码
{
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "tokenType": "Bearer"
}

访问普通登录接口:

shell 复制代码
curl http://localhost:8080/api/profile \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...'

访问管理员接口:

shell 复制代码
curl http://localhost:8080/api/admin/users \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...'

删除用户:

shell 复制代码
curl -X DELETE http://localhost:8080/api/users/1 \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...'

admin 可以删除。

tom 访问删除接口会返回 403

方法级权限控制

URL 级权限适合粗粒度控制:

java 复制代码
.requestMatchers("/api/admin/**").hasRole("ADMIN")

方法级权限适合业务层控制:

java 复制代码
@PreAuthorize("hasAuthority('user:delete')")
public void deleteUser(Long id) {
    userRepository.deleteById(id);
}

也可以拿参数做判断:

java 复制代码
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
public UserDetail getUserDetail(Long userId) {
    return userRepository.findDetail(userId);
}

上面这类写法需要自定义登录用户对象,把 id 放到 principal 里。

普通项目里常见分层方式:

text 复制代码
SecurityFilterChain 控制大方向
@PreAuthorize 控制具体业务动作

比如:

java 复制代码
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping
    @PreAuthorize("hasAuthority('user:list')")
    public List<String> list() {
        return List.of("admin", "tom");
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('user:delete')")
    public String delete(@PathVariable Long id) {
        return "deleted " + id;
    }
}

CSRF 到底该不该关

CSRF 是跨站请求伪造。

它主要针对这种场景:

text 复制代码
浏览器会自动携带 Cookie
服务端又靠 Cookie 判断登录状态

如果是传统后台页面:

text 复制代码
表单登录 + Session + Cookie

通常应该开启 CSRF。

如果是前后端分离 API:

text 复制代码
Authorization: Bearer token

浏览器不会自动给第三方网站带上这个请求头,很多项目会关闭 CSRF。

所以不是一句"前后端分离就一定关闭"这么简单。

更准确的判断是:

text 复制代码
登录凭证是否会被浏览器自动携带。

靠 Cookie 自动带身份,要认真处理 CSRF。

靠 Authorization 请求头带 Token,通常可以关闭 CSRF。

Session 和 JWT 怎么选

两种方式没有绝对优劣。

方案 优点 缺点 常见场景
Session 服务端可控,踢人、续期、失效都直接 集群需要共享 Session 或粘性会话 后台管理系统、传统 Web
JWT 无状态,适合跨端和微服务 Token 签发后不容易立刻失效 App、小程序、开放 API、前后端分离

如果系统需要强制下线、单端登录、后台封禁立即生效,JWT 还需要配合:

  • Redis 黑名单
  • 用户 tokenVersion
  • 短过期时间
  • Refresh Token

JWT 不是万能登录方案,只是无状态认证方案。

常见坑

访问接口一直 401

先检查登录接口是否放行:

java 复制代码
.requestMatchers("/api/auth/login").permitAll()

再检查 JWT 请求头是否正确:

text 复制代码
Authorization: Bearer token内容

Bearer 后面有一个空格。

明明有 ADMIN 还是 403

检查角色是否带了 ROLE_

下面写法:

java 复制代码
hasRole("ADMIN")

实际检查:

text 复制代码
ROLE_ADMIN

数据库里如果存的是 ADMIN,转换成 GrantedAuthority 时需要补上:

java 复制代码
new SimpleGrantedAuthority("ROLE_" + role)

@PreAuthorize 不生效

配置类需要开启:

java 复制代码
@EnableMethodSecurity

Spring Boot 默认不会自动打开方法级权限控制。

密码一直匹配失败

数据库密码必须是 PasswordEncoder 加密后的结果。

错误示例:

text 复制代码
123456

正确示例:

text 复制代码
{bcrypt}$2a$10$......

如果使用 DelegatingPasswordEncoder,密文前面通常会有 {bcrypt}

JWT 过滤器里异常直接打断请求

Token 过期、签名错误、格式错误都可能抛异常。

生产代码建议在过滤器里捕获 JWT 解析异常,然后交给统一异常响应处理,避免返回默认错误页。

示例:

java 复制代码
try {
    String username = jwtService.getUsername(token);
    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
    if (jwtService.isValid(token, userDetails)) {
        // 设置认证信息
    }
} catch (Exception ex) {
    SecurityContextHolder.clearContext();
}

总结

Spring Security 可以按这条线理解:

text 复制代码
请求先进入过滤器链
过滤器负责识别登录身份
认证成功后放入 SecurityContext
授权规则决定接口能不能访问
Controller 和 Service 只处理业务

常用配置记住这几个点就够用:

  • SecurityFilterChain 配 URL 访问规则
  • UserDetailsService 从数据库加载用户
  • PasswordEncoder 负责密码加密和匹配
  • AuthenticationManager 负责执行登录认证
  • SecurityContextHolder 保存当前登录状态
  • @EnableMethodSecurity 开启方法权限
  • @PreAuthorize 做细粒度权限判断
  • JWT 项目一般配置 STATELESS,并把自定义过滤器加到过滤器链里

真正落地时,不要只盯着"登录成功"。还要一起处理密码加密、权限模型、未登录响应、无权限响应、Token 过期、强制下线和接口分层。安全代码最怕散,规则集中、边界清楚,后面扩展角色和权限才不容易乱。

相关推荐
曹牧2 小时前
文档格式:OFD
java
豆角焖肉2 小时前
Maven进阶与搭建私服
java·maven
大不点wow2 小时前
Java序列化与反序列化:让对象走出JVM
java·开发语言·jvm
噢,我明白了3 小时前
Java中日期和字符串的处理
java·开发语言·日期
dkbnull3 小时前
Spring Boot请求处理组件对比详解
java·spring boot
jun_bai3 小时前
Orthanc服务器使用java上传dicom影像文件
java·运维·服务器
顺风尿一寸3 小时前
记一次 Spring AOP 与定时任务引发的死锁排查
java
用户446139430273 小时前
从单体到微服务:我们项目的拆分思路和踩坑记录
java
TDengine (老段)3 小时前
TDengine 免费版说明
java·大数据·数据库·物联网·时序数据库·tdengine