SpringBoot(11):Spring Security 入门——让你的项目加上登录墙

SpringBoot(11):Spring Security 入门------让你的项目加上登录墙

上周帮朋友看一个项目,接口全部裸奔------没有任何登录校验,随便一个人拿到 URL 就能调管理员接口删数据。我问他怎么不加个登录,他说"Spring Security 太复杂了,配了一周没跑通"。这话说得不冤,Spring Security 上手确实陡。但如果你搞懂了它的核心流程(认证 + 授权 + 过滤器链),其实没那么难。这篇文章从零开始,把 Spring Security 的原理、核心组件、源码、配置方式、JWT 整合、权限控制全部讲一遍。看完你能在自己项目里搭一套完整的登录认证体系。

问题:为什么需要 Spring Security

没有安全框架时,登录校验通常这么写:

kotlin 复制代码
@RestController
@RequestMapping("/api")
public class OrderController {

    @Autowired
    private HttpSession session;

    @GetMapping("/orders")
    public Result listOrders() {
        User user = (User) session.getAttribute("loginUser");
        if (user == null) {
            return Result.fail("未登录");
        }
        if (!user.getRoles().contains("ADMIN")) {
            return Result.fail("无权限");
        }
        return Result.success(orderService.list());
    }
}

每个接口都要写一遍 if (user == null),遗漏一个就不安全。角色判断散落在各个 Controller 里,改一下权限逻辑得改一堆地方。

Spring Security 解决的问题:

痛点 Spring Security 的解法
每个接口手动校验登录 过滤器链自动拦截,未登录跳转登录页
权限判断散落各处 注解 + 统一配置,集中管理
密码明文存储 内置 BCrypt 加密
Session 管理麻烦 支持 Session + JWT 两种模式
CSRF 攻击 内置 CSRF 防护
暴力破解 内置登录限速、账号锁定

Spring Security 核心架构

整个 Spring Security 就是一条过滤器链(FilterChain)。每个请求进来,按顺序经过一系列 Filter,每个 Filter 负责一件事:认证、授权、CSRF 校验、CORS 处理等。全部通过后,请求才到达你的 Controller。

核心组件

组件 作用 类比
SecurityFilterChain 一组 Filter 的有序集合 保安队
Authentication 认证信息(谁、密码、权限) 工牌
SecurityContext 存储当前用户的 Authentication 工牌夹
AuthenticationManager 认证管理器,负责验证 门禁系统
ProviderManager AuthenticationManager 的默认实现 门禁总控
AuthenticationProvider 具体的认证逻辑(账号密码/短信/证书) 某种验证方式
UserDetailsService 加载用户信息 HR 查档案
PasswordEncoder 密码加密比对 密码保险箱
AccessDecisionManager 授权决策 权限审批员
SecurityContextHolder 线程级上下文持有者 全局工牌架

它们之间的关系:

scss 复制代码
请求 → FilterChainProxy → 各 Filter
                              ↓
                    UsernamePasswordAuthenticationFilter
                              ↓
                    AuthenticationManager (ProviderManager)
                              ↓
                    DaoAuthenticationProvider
                              ↓
                    UserDetailsService.loadUserByUsername()
                              ↓
                    PasswordEncoder.matches()
                              ↓
                    认证成功 → SecurityContext 存入 Authentication
                              ↓
                    FilterSecurityInterceptor(授权检查)
                              ↓
                    Controller

快速上手:30 秒加上登录墙

引入依赖

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

就这一个依赖,启动项目后所有接口都自动加上了登录墙。访问任何接口会跳转到 Spring Security 自带的登录页(/login),默认用户名 user,密码在启动日志里打印:

sql 复制代码
Using generated security password: 8f3a7b2c-1d4e-4f6a-b8c9-2e3d4a5b6c7d

这个密码每次启动都变,开发时用不方便。先改成固定密码。

第一个配置:自定义用户名密码

yaml 复制代码
spring:
  security:
    user:
      name: admin
      password: admin123
      roles: ADMIN

这样就能用 admin / admin123 登录了。

用 Java 配置替代 YAML

实际项目用 Java 配置更灵活:

less 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/login", "/register").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/**").authenticated()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/home")
                .failureUrl("/login?error=true")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout=true")
                .permitAll()
            )
            .csrf(csrf -> csrf.disable());

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails admin = User.builder()
                .username("admin")
                .password(passwordEncoder.encode("admin123"))
                .roles("ADMIN")
                .build();
        UserDetails user = User.builder()
                .username("zhangsan")
                .password(passwordEncoder.encode("123456"))
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(admin, user);
    }

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

这段配置做了几件事:

  1. /public/**/login/register 放行
  2. /admin/** 需要 ADMIN 角色
  3. /api/** 需要登录
  4. 其他所有请求都要登录
  5. 表单登录,自定义登录页
  6. 关闭 CSRF(前后端分离时通常关闭)

认证流程源码分析

1. 请求进入过滤器链

所有请求经过 FilterChainProxy。它是 Spring Security 的入口 Filter,内部维护了一个 SecurityFilterChain 列表:

vbscript 复制代码
// org.springframework.security.web.FilterChainProxy
public class FilterChainProxy extends GenericFilterBean {

    private List<SecurityFilterChain> filterChains;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) {
        doFilterInternal(request, response, chain);
    }

    private void doFilterInternal(ServletRequest request, ServletResponse response,
                                   FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        // 找到匹配当前请求的过滤器链
        List<Filter> filters = getFilters(httpRequest);
        if (filters == null || filters.size() == 0) {
            chain.doFilter(request, response);
            return;
        }
        // 沿着过滤器链执行
        VirtualFilterChain virtualFilterChain =
            new VirtualFilterChain(chain, filters);
        virtualFilterChain.doFilter(request, response);
    }
}

2. UsernamePasswordAuthenticationFilter 拦截登录请求

这个 Filter 只处理 POST /login

scala 复制代码
// org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                 HttpServletResponse response)
            throws AuthenticationException {
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        UsernamePasswordAuthenticationToken authRequest =
            UsernamePasswordAuthenticationToken.unauthenticated(username, password);

        // 交给 AuthenticationManager 认证
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

注意这里创建了一个 UsernamePasswordAuthenticationToken,此时它的 authenticated 属性是 false,表示还没认证。

3. ProviderManager 委托给 DaoAuthenticationProvider

java 复制代码
// org.springframework.security.authentication.ProviderManager
public class ProviderManager implements AuthenticationManager {

    private List<AuthenticationProvider> providers;

    @Override
    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        for (AuthenticationProvider provider : providers) {
            if (!provider.supports(authentication.getClass())) {
                continue;
            }
            // 委托给具体的 Provider
            result = provider.authenticate(authentication);
            if (result != null) {
                return result;
            }
        }
        throw new ProviderNotFoundException("No provider found");
    }
}

4. DaoAuthenticationProvider 调用 UserDetailsService

scala 复制代码
// org.springframework.security.authentication.dao.DaoAuthenticationProvider
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    private UserDetailsService userDetailsService;
    private PasswordEncoder passwordEncoder;

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        String presentedPassword = authentication.getCredentials().toString();
        // 密码比对
        if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
            throw new BadCredentialsException("Bad credentials");
        }
    }

    @Override
    protected UserDetails retrieveUser(String username,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        // 从 UserDetailsService 加载用户
        return this.userDetailsService.loadUserByUsername(username);
    }
}

5. 认证成功,存入 SecurityContext

认证成功后,AbstractAuthenticationProcessingFilter 把 Authentication 存入 SecurityContextHolder

typescript 复制代码
// org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter
public abstract class AbstractAuthenticationProcessingFilter {

    private void successfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain,
            Authentication authentication) {
        // 存入 SecurityContext
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 调用成功处理器
        successHandler.onAuthenticationSuccess(request, response, chain, authentication);
    }
}

6. 后续请求自动获取认证信息

SecurityContextPersistenceFilter 在每个请求开始时,从 Session 中恢复 SecurityContext

scala 复制代码
// org.springframework.security.web.context.SecurityContextPersistenceFilter
public class SecurityContextPersistenceFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        // 从 Session 加载 SecurityContext
        SecurityContext contextBeforeChainExecution =
            repo.loadContext(httpRequest);
        SecurityContextHolder.setContext(contextBeforeChainExecution);
        try {
            chain.doFilter(request, response);
        } finally {
            // 保存回 Session
            SecurityContextHolder.clearContext();
        }
    }
}

整个认证流程的数据流:

scss 复制代码
用户提交用户名密码
    ↓
UsernamePasswordAuthenticationFilter
    ↓ 创建未认证的 Token
ProviderManager
    ↓ 遍历 Providers
DaoAuthenticationProvider
    ↓ 调用 UserDetailsService
UserDetailsService.loadUserByUsername()
    ↓ 返回 UserDetails
PasswordEncoder.matches()
    ↓ 密码匹配
认证成功 → 创建已认证的 Token
    ↓
SecurityContextHolder.setAuthentication()
    ↓ 存入 Session
后续请求 → SecurityContextPersistenceFilter 从 Session 恢复

数据库用户认证

内存用户只适合 demo。实际项目用户信息存数据库。

建表

sql 复制代码
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(100) NOT NULL,
    nickname VARCHAR(50),
    enabled TINYINT(1) DEFAULT 1,
    account_non_expired TINYINT(1) DEFAULT 1,
    account_non_locked TINYINT(1) DEFAULT 1,
    credentials_non_expired TINYINT(1) DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    code VARCHAR(50) NOT NULL UNIQUE
);

CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id)
);

CREATE TABLE sys_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    code VARCHAR(100) NOT NULL UNIQUE,
    type VARCHAR(20) NOT NULL COMMENT 'MENU/BUTTON/API'
);

CREATE TABLE sys_role_permission (
    role_id BIGINT NOT NULL,
    permission_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, permission_id)
);

实体类

less 复制代码
@Entity
@Table(name = "sys_user")
public class SysUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String nickname;
    private Boolean enabled;
    private Boolean accountNonExpired;
    private Boolean accountNonLocked;
    private Boolean credentialsNonExpired;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "sys_user_role",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<SysRole> roles;

    public UserDetails toUserDetails() {
        List<GrantedAuthority> authorities = roles.stream()
                .flatMap(role -> role.getPermissions().stream())
                .map(p -> new SimpleGrantedAuthority(p.getCode()))
                .collect(Collectors.toList());
        authorities.addAll(roles.stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r.getCode()))
                .collect(Collectors.toList()));

        return User.builder()
                .username(username)
                .password(password)
                .disabled(!enabled)
                .accountExpired(!accountNonExpired)
                .accountLocked(!accountNonLocked)
                .credentialsExpired(!credentialsNonExpired)
                .authorities(authorities)
                .build();
    }
}

自定义 UserDetailsService

java 复制代码
@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private SysUserRepository userRepository;

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

修改配置

less 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/login", "/register",
                                 "/css/**", "/js/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/**").authenticated()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/home")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout")
                .permitAll()
            )
            .csrf(csrf -> csrf.disable())
            .userDetailsService(userDetailsService);

        return http.build();
    }

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

JWT 无状态认证

前后端分离项目用 JWT 比 Session 更合适:不用服务器存状态,方便水平扩展。

JWT vs Session

对比项 Session JWT
存储位置 服务器内存/Redis 客户端(请求头)
水平扩展 需要Session共享 天然支持
跨域 需要额外处理 天然支持
安全性 SessionId 泄露风险 Token 泄露风险
注销 删Session即可 需要黑名单机制
适用场景 服务端渲染 前后端分离

引入 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>

JWT 工具类

scss 复制代码
@Component
public class JwtUtils {

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

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

    private SecretKey getSigningKey() {
        byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }

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

        return Jwts.builder()
                .claims(claims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey())
                .compact();
    }

    public String extractUsername(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
    }

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

    private boolean isTokenExpired(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getExpiration()
                .before(new Date());
    }
}

JWT 登录接口

less 复制代码
@RestController
@RequestMapping("/auth")
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private CustomUserDetailsService userDetailsService;
    @Autowired
    private JwtUtils jwtUtils;

    @PostMapping("/login")
    public Result<LoginResponse> login(@RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(), request.getPassword()));

        SecurityContextHolder.getContext().setAuthentication(authentication);

        UserDetails userDetails = userDetailsService
                .loadUserByUsername(request.getUsername());
        String token = jwtUtils.generateToken(userDetails);

        return Result.success(new LoginResponse(token, userDetails.getUsername()));
    }

    @GetMapping("/info")
    public Result<UserInfo> getUserInfo(@AuthenticationPrincipal UserDetails userDetails) {
        UserInfo info = new UserInfo();
        info.setUsername(userDetails.getUsername());
        info.setAuthorities(userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        return Result.success(info);
    }
}

JWT 认证过滤器

scala 复制代码
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtils jwtUtils;
    @Autowired
    private CustomUserDetailsService userDetailsService;

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

        if (token != null && jwtUtils.isTokenValid(token,
                userDetailsService.loadUserByUsername(
                        jwtUtils.extractUsername(token)))) {
            String username = jwtUtils.extractUsername(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authentication =
                    UsernamePasswordAuthenticationToken.authenticated(
                            userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource()
                    .buildDetails(request));

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

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

JWT 配置

java 复制代码
@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/login", "/auth/register",
                                 "/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthenticationFilter,
                    UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

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

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

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

配置要点:

  • sessionCreationPolicy(STATELESS) --- 告诉 Spring Security 不使用 Session
  • addFilterBefore --- 把 JWT 过滤器放在认证过滤器之前
  • 关闭 CSRF(无状态不需要)

权限控制

方式一:URL 级别权限

less 复制代码
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers("/user/**").hasAnyRole("ADMIN", "USER")
    .requestMatchers("/api/order/**").hasAuthority("order:view")
    .requestMatchers("/api/order/create").hasAuthority("order:create")
    .anyRequest().authenticated()
);

hasRole("ADMIN") 底层会自动加 ROLE_ 前缀,匹配 ROLE_ADMINhasAuthority 不加前缀,直接匹配。

方式二:方法级别权限

less 复制代码
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

然后在 Controller 或 Service 上用注解:

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

    @GetMapping
    @PreAuthorize("hasRole('ADMIN')")
    public Result listUsers() {
        return Result.success(userService.list());
    }

    @GetMapping("/{id}")
    @PreAuthorize("hasAuthority('user:view') or #id == authentication.principal.id")
    public Result getUser(@PathVariable Long id) {
        return Result.success(userService.getById(id));
    }

    @PostMapping
    @PreAuthorize("hasAuthority('user:create')")
    public Result createUser(@RequestBody UserRequest request) {
        return Result.success(userService.create(request));
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') and hasAuthority('user:delete')")
    public Result deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return Result.success();
    }

    @GetMapping("/profile")
    public Result getProfile(@AuthenticationPrincipal UserDetails userDetails) {
        return Result.success(userService.getByUsername(userDetails.getUsername()));
    }
}

SpEL 表达式常用写法:

表达式 说明
hasRole('ADMIN') 拥有 ADMIN 角色
hasAnyRole('ADMIN','USER') 拥有任一角色
hasAuthority('user:delete') 拥有 user:delete 权限
hasAnyAuthority('a','b') 拥有任一权限
isAuthenticated() 已登录
isAnonymous() 未登录
permitAll 放行
denyAll 拒绝所有
#id == authentication.principal.id 参数等于当前用户ID

方式三:自定义权限评估

java 复制代码
@Component
public class SecurityService {

    @Autowired
    private SysPermissionRepository permissionRepository;

    public boolean hasPermission(String permissionCode) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            return false;
        }
        return auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals(permissionCode));
    }

    public boolean isOwner(Long userId) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null) return false;
        UserDetails userDetails = (UserDetails) auth.getPrincipal();
        SysUser user = userRepository.findByUsername(userDetails.getUsername())
                .orElse(null);
        return user != null && user.getId().equals(userId);
    }
}

在 SpEL 中使用:

less 复制代码
@PreAuthorize("@securityService.hasPermission('order:cancel')")
@PostMapping("/order/{id}/cancel")
public Result cancelOrder(@PathVariable Long id) {
    orderService.cancel(id);
    return Result.success();
}

@PreAuthorize("@securityService.isOwner(#userId) or hasRole('ADMIN')")
@GetMapping("/user/{userId}/profile")
public Result getProfile(@PathVariable Long userId) {
    return Result.success(userService.getById(userId));
}

密码加密

Spring Security 默认用 BCrypt,这是目前推荐的密码哈希算法。

typescript 复制代码
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

使用:

scss 复制代码
@Service
public class UserService {

    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private SysUserRepository userRepository;

    public void register(RegisterRequest request) {
        if (userRepository.existsByUsername(request.getUsername())) {
            throw new BusinessException("用户名已存在");
        }
        SysUser user = new SysUser();
        user.setUsername(request.getUsername());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setNickname(request.getNickname());
        user.setEnabled(true);
        user.setAccountNonExpired(true);
        user.setAccountNonLocked(true);
        user.setCredentialsNonExpired(true);
        userRepository.save(user);
    }
}

BCrypt 的特点:

特点 说明
自带盐值 每次加密自动生成随机盐,不需要单独存
慢哈希 故意算得慢,防止暴力破解
长度固定 输出 60 个字符
强度可调 BCryptPasswordEncoder(12),数字越大越慢,默认 10

验证密码不需要手动取出盐值,直接 passwordEncoder.matches(rawPassword, encodedPassword) 即可。

自定义登录成功/失败处理

前后端分离项目通常返回 JSON 而不是重定向:

typescript 复制代码
@Component
public class JsonAuthenticationSuccessHandler
        implements AuthenticationSuccessHandler {

    @Autowired
    private JwtUtils jwtUtils;
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_OK);

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

        Map<String, Object> result = new HashMap<>();
        result.put("code", 200);
        result.put("message", "登录成功");
        result.put("data", Map.of(
            "token", token,
            "username", userDetails.getUsername(),
            "authorities", userDetails.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority).toList()
        ));

        response.getWriter().write(objectMapper.writeValueAsString(result));
    }
}

@Component
public class JsonAuthenticationFailureHandler
        implements AuthenticationFailureHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        String message = "登录失败";
        if (exception instanceof BadCredentialsException) {
            message = "用户名或密码错误";
        } else if (exception instanceof DisabledException) {
            message = "账号已被禁用";
        } else if (exception instanceof LockedException) {
            message = "账号已被锁定";
        } else if (exception instanceof AccountExpiredException) {
            message = "账号已过期";
        }

        Map<String, Object> result = new HashMap<>();
        result.put("code", 401);
        result.put("message", message);

        response.getWriter().write(objectMapper.writeValueAsString(result));
    }
}

配置:

less 复制代码
http.formLogin(form -> form
    .loginProcessingUrl("/auth/login")
    .successHandler(jsonAuthenticationSuccessHandler)
    .failureHandler(jsonAuthenticationFailureHandler)
);

未登录和权限不足处理

java 复制代码
@Component
public class JsonAccessDeniedHandler implements AccessDeniedHandler {

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

@Component
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {

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

配置:

less 复制代码
http.exceptionHandling(ex -> ex
    .authenticationEntryPoint(jsonAuthenticationEntryPoint)
    .accessDeniedHandler(jsonAccessDeniedHandler)
);

CORS 配置

前后端分离项目前后端不同域,需要处理跨域:

java 复制代码
@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(List.of("http://localhost:3000"));
    configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    configuration.setAllowedHeaders(List.of("*"));
    configuration.setAllowCredentials(true);
    configuration.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}
ini 复制代码
http.cors(Customizer.withDefaults());

实战案例:RBAC 权限管理系统

把上面的内容整合成一个完整的 RBAC(基于角色的访问控制)系统。

初始化数据

java 复制代码
@Component
public class DataInitializer implements CommandLineRunner {

    @Autowired
    private SysUserRepository userRepository;
    @Autowired
    private SysRoleRepository roleRepository;
    @Autowired
    private SysPermissionRepository permissionRepository;
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private SysUserRoleRepository userRoleRepository;
    @Autowired
    private SysRolePermissionRepository rolePermissionRepository;

    @Override
    public void run(String... args) {
        if (roleRepository.count() > 0) return;

        SysRole adminRole = new SysRole("管理员", "ADMIN");
        SysRole userRole = new SysRole("普通用户", "USER");
        roleRepository.saveAll(List.of(adminRole, userRole));

        List<SysPermission> permissions = List.of(
            new SysPermission("用户查看", "user:view", "MENU"),
            new SysPermission("用户新增", "user:create", "BUTTON"),
            new SysPermission("用户编辑", "user:edit", "BUTTON"),
            new SysPermission("用户删除", "user:delete", "BUTTON"),
            new SysPermission("订单查看", "order:view", "MENU"),
            new SysPermission("订单取消", "order:cancel", "BUTTON"),
            new SysPermission("商品管理", "product:manage", "MENU")
        );
        permissionRepository.saveAll(permissions);

        SysUser admin = new SysUser();
        admin.setUsername("admin");
        admin.setPassword(passwordEncoder.encode("admin123"));
        admin.setNickname("系统管理员");
        admin.setEnabled(true);
        admin.setAccountNonExpired(true);
        admin.setAccountNonLocked(true);
        admin.setCredentialsNonExpired(true);
        userRepository.save(admin);

        SysUser user = new SysUser();
        user.setUsername("zhangsan");
        user.setPassword(passwordEncoder.encode("123456"));
        user.setNickname("张三");
        user.setEnabled(true);
        user.setAccountNonExpired(true);
        user.setAccountNonLocked(true);
        user.setCredentialsNonExpired(true);
        userRepository.save(user);

        userRoleRepository.save(new SysUserRole(admin.getId(), adminRole.getId()));
        userRoleRepository.save(new SysUserRole(user.getId(), userRole.getId()));

        for (SysPermission p : permissions) {
            rolePermissionRepository.save(
                    new SysRolePermission(adminRole.getId(), p.getId()));
        }
        rolePermissionRepository.save(new SysRolePermission(userRole.getId(),
                permissions.get(4).getId()));
        rolePermissionRepository.save(new SysRolePermission(userRole.getId(),
                permissions.get(6).getId()));
    }
}

完整的安全配置

less 复制代码
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class FullSecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    @Autowired
    private CustomUserDetailsService userDetailsService;
    @Autowired
    private JsonAuthenticationEntryPoint authenticationEntryPoint;
    @Autowired
    private JsonAccessDeniedHandler accessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .cors(Customizer.withDefaults())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/login", "/auth/register",
                                 "/public/**", "/captcha").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthenticationFilter,
                    UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

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

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

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

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:3000"));
        configuration.setAllowedMethods(
                List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

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

前端对接要点

前端拿到 JWT 后,每个请求在 Header 里带上:

ini 复制代码
axios.interceptors.request.use(config => {
    const token = localStorage.getItem('token');
    if (token) {
        config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
});

axios.interceptors.response.use(
    response => response,
    error => {
        if (error.response?.status === 401) {
            localStorage.removeItem('token');
            window.location.href = '/login';
        }
        if (error.response?.status === 403) {
            message.error('权限不足');
        }
        return Promise.reject(error);
    }
);

Spring Security 过滤器链完整顺序

Spring Security 默认注册的过滤器(按执行顺序):

顺序 过滤器 作用
1 DisableEncodeUrlFilter 禁用URL编码SessionId
2 WebAsyncManagerIntegrationFilter 异步请求安全上下文集成
3 SecurityContextPersistenceFilter 从Session恢复SecurityContext
4 HeaderWriterFilter 写安全响应头(X-Frame-Options等)
5 CorsFilter CORS 跨域处理
6 CsrfFilter CSRF 防护
7 LogoutFilter 登出处理
8 UsernamePasswordAuthenticationFilter 表单登录认证
9 DefaultLoginPageGeneratingFilter 生成默认登录页
10 BasicAuthenticationFilter HTTP Basic 认证
11 RequestCacheAwareFilter 请求缓存恢复
12 SecurityContextHolderFilter 安全上下文管理
13 RememberMeAuthenticationFilter 记住我认证
14 AnonymousAuthenticationFilter 匿名用户认证
15 SessionManagementFilter Session 管理
16 ExceptionTranslationFilter 异常翻译处理
17 FilterSecurityInterceptor 权限校验

JWT 过滤器通过 addFilterBefore 插入到 UsernamePasswordAuthenticationFilter 之前。

Remember-Me(记住我)

less 复制代码
http.rememberMe(remember -> remember
    .key("uniqueAndSecret")
    .tokenValiditySeconds(7 * 24 * 3600)
    .rememberMeParameter("remember-me")
    .userDetailsService(userDetailsService)
);

登录时带上 remember-me=true 参数,Spring Security 会生成一个持久化 Token 存在 Cookie 里,有效期 7 天。

底层实现:RememberMeAuthenticationFilter 检测到 Cookie 中有 remember-me Token 时,自动调用 UserDetailsService 加载用户信息,跳过登录流程。

常见配置对比

场景 配置
服务端渲染 formLogin + Session
前后端分离 JWT + STATELESS
只保护部分接口 requestMatchers 放行 + authenticated
全部放行(开发时) http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
禁用 Security 在配置类上加 @Profile("test") 或排除自动配置

Spring Security 配置速查

需求 代码
放行路径 .requestMatchers("/xxx").permitAll()
需要登录 .anyRequest().authenticated()
需要角色 .requestMatchers("/admin/**").hasRole("ADMIN")
需要权限 .requestMatchers("/api/x").hasAuthority("x:view")
表单登录 .formLogin(form -> form.loginPage("/login"))
关闭 CSRF .csrf(csrf -> csrf.disable())
无状态 .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
CORS .cors(Customizer.withDefaults()) + 配置 Bean
注销 .logout(l -> l.logoutUrl("/logout"))
记住我 .rememberMe(r -> r.key("xxx"))

总结

知识点 要点
核心原理 过滤器链,每个请求依次经过认证、授权等 Filter
认证流程 Token → AuthenticationManager → Provider → UserDetailsService → PasswordEncoder
配置方式 SecurityFilterChain Bean,Lambda DSL
用户来源 内存、数据库(UserDetailsService)
密码加密 BCryptPasswordEncoder,自带盐值
JWT 认证 自定义 Filter,解析 Token 设置 SecurityContext
权限控制 URL 级别(requestMatchers)+ 方法级别(@PreAuthorize)
异常处理 AuthenticationEntryPoint(未登录)+ AccessDeniedHandler(权限不足)
CORS CorsConfigurationSource Bean
RBAC 用户-角色-权限三表模型,GrantedAuthority 承载权限

Spring Security 看着吓人,拆开看就是一条过滤器链。认证靠 AuthenticationManager 委托给 AuthenticationProvider,授权靠 FilterSecurityInterceptor 检查权限。搞清楚这条链上每个节点的职责,剩下的就是配配置的事。前后端分离用 JWT,服务端渲染用 Session,权限细粒度控制用 @PreAuthorize。数据库用户实现 UserDetailsService,密码加密用 BCrypt。这几个组件搞明白了,Spring Security 就算过了入门这道坎。

相关推荐
掘金者阿豪3 小时前
高可用读写分离实战(二):我把数据库主库停了,结果整个集群的反应和我想象的不一样
后端
掘金者阿豪3 小时前
《高可用读写分离集群实战》系列(一)
后端
Dilee3 小时前
Spring AI 2.0.0 Prompt 最小 Demo:system、user、template 到底怎么分工
后端
未秃头的程序猿3 小时前
Java 26正式发布!这3个新特性,让代码量直接减半
java·后端·面试
小旭Coding4 小时前
卧靠!Go 传给前端的 int64 竟然变成了这个?
后端
用户298698530144 小时前
Word 文档文本查找与替换的 Java 实现方案
java·后端
kunge20134 小时前
深度剖析Claude Code 的CLAUDE.md加载逻辑
后端·vibecoding