Spring Boot 第四周:集成 Spring Security 与 JWT 鉴权实践

前言

在前后端分离的开发模式下,传统的 Session 认证方式已逐渐被 Token 认证取代。JWT(JSON Web Token)因其无状态、跨域友好、易于扩展等特点,成为 RESTful API 最流行的认证方案。本周我从零开始,在 Spring Boot 项目中集成了 Spring Security 和 JWT,并初始化了 Vue3 + Vite 前端项目,为前后端联调做好了准备。本文将详细记录这一过程,包括每一步的实现思路、关键代码和踩坑记录。

技术栈

  • 后端:Spring Boot 3.x, Spring Security 7.x, JJWT 0.12.5, MySQL, JPA

  • 前端:Vue3, Vite, axios, Vue Router 4

  • 工具:IntelliJ IDEA, Postman, MySQL Workbench

  • 源码backend4 frontend

整体流程

  1. 注册:用户提供用户名和密码,密码经 BCrypt 加密后存入数据库,同时分配默认角色(USER)。

  2. 登录:用户提交用户名和密码,Spring Security 的 AuthenticationManager 验证凭据,成功后生成 JWT Token 返回给客户端。

  3. 后续请求 :客户端在 HTTP 请求头中携带 Authorization: Bearer <token>

  4. JWT 过滤器:Spring Security 的过滤器链中,自定义的 JwtAuthenticationFilter 拦截每个请求,从请求头提取 Token,验证签名和有效期,若有效则将用户信息(含角色)设置到 SecurityContextHolder 中。

  5. 授权检查 :Spring Security 根据 Controller 方法上的 @PreAuthorize注解,判断当前用户是否拥有所需角色,若无则返回 403 Forbidden。

  6. 前端 Vue3项目通过 axios 拦截器自动携带 Token,并通过路由守卫保护需要登录的页面。

一、后端实现

1.1 添加依赖

pom.xml中添加 Spring Security 和 JWT 依赖:

复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<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>

说明:

  • spring-boot-starter-security会自动配置 Spring Security 的基础设施。

  • JJWT 是 Java 中广泛使用的 JWT 库,jjwt-api是接口,jjwt-impl是实现,jjwt-jackson用于 JSON 序列化/反序列化。

添加后点击 IDEA 右下角的 Load Maven Changes​ 下载依赖。

1.2 创建用户实体与角色枚举

1.2 .1 Role 枚举com.example.backend.model.Role):
复制代码
public enum Role {
    USER,
    ADMIN
}

注意: ​ 枚举常量名称必须是大写,因为 Spring Security 的 hasRole()会自动添加 ROLE_前缀,所以数据库中存储的也应该是大写 USERADMIN

1.2 .2 创建 User 实体(实现 UserDetails)

UserDetails是 Spring Security 提供的接口,用于封装用户信息。我们让 User实体直接实现该接口,简化代码。

复制代码
package com.example.backend.model;

import jakarta.persistence.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

@Entity
@Table(name = "users")
public class User implements UserDetails {

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

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

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;

    // 无参构造
    public User() {}

    // 全参构造
    public User(Long id, String username, String password, Role role) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.role = role;
    }

    // --- UserDetails 接口方法 让 User 实体直接充当 Spring Security 的用户对象,简化代码---
    // 将 Role枚举转换为 ROLE_USER或 ROLE_ADMIN格式的权限
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 将角色转换为 GrantedAuthority
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true; // 账户未过期
    }

    @Override
    public boolean isAccountNonLocked() {
        return true; // 账户未锁定
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true; // 凭证未过期
    }

    @Override
    public boolean isEnabled() {
        return true; // 账户启用
    }

    // --- Getter 和 Setter ---
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public void setUsername(String username) { this.username = username; }
    public void setPassword(String password) { this.password = password; }
    public Role getRole() { return role; }
    public void setRole(Role role) { this.role = role; }
}

关键点解释:

  • @Enumerated(EnumType.STRING):将枚举以字符串形式存入数据库(例如 "USER"),而不是默认的数字序号,便于阅读和调试。

  • getAuthorities():返回一个包含单个 SimpleGrantedAuthority的列表,其值为 "ROLE_USER""ROLE_ADMIN"。Spring Security 的 hasRole('USER')会自动添加 ROLE_前缀,因此两者匹配。

  • 实现 UserDetails后,Spring Security 可以直接使用该对象进行认证和授权。

1.2.3 UserRepository
复制代码
package com.example.backend.repository;

import com.example.backend.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // 根据用户名查询用户(用于登录认证)
    Optional<User> findByUsername(String username);

    // 判断用户名是否已存在(用于注册校验)
    boolean existsByUsername(String username);
}

说明:

  • findByUsername返回 Optional,优雅地处理用户不存在的情况。

  • existsByUsername返回布尔值,用于注册时检查用户名唯一性。

1.3 加载用户 ------ CustomUserDetailsService

Spring Security 在进行认证时,需要知道如何从数据库加载用户信息。我们需要实现 UserDetailsService接口。

复制代码
// 从数据库加载用户信息,配置 Spring Security 的安全策略(密码编码、放行路径、禁用 CSRF 等)
package com.example.backend.service;

import com.example.backend.model.User;
import com.example.backend.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
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;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    // UsernameNotFoundException: 查到用户就返回,查不到就抛异常
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
        return user;  // User 本身实现了 UserDetails,直接返回
    }
}

工作原理:

  • 当用户登录时,Spring Security 的 DaoAuthenticationProvider会调用此方法获取用户信息。

  • 然后它会用 PasswordEncoder比对用户提交的密码与数据库中存储的加密密码是否匹配。

  • 如果匹配,认证成功;否则抛出 BadCredentialsException

1.4 安全配置 ------ SecurityConfig

这是 Spring Security 的核心配置类。我们需要配置密码编码器、认证管理器、请求授权规则,并注册 JWT 过滤器。

复制代码
package com.example.backend.config;

import com.example.backend.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;
// 标记为配置类
@Configuration
//启用 Spring Security 的 Web 安全支持
@EnableWebSecurity
// 启用方法安全(@PreAuthorize 等注解),允许我们在 Controller 的方法上使用 @PreAuthorize、@PostAuthorize等注解进行细粒度权限控制
@EnableMethodSecurity
public class SecurityConfig {

    @Autowired
    private CustomUserDetailsService customUserDetailsService;

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;  // 注入 JWT 过滤器

    //密码编码器,用于加密和验证密码
    @Bean
    public PasswordEncoder passwordEncoder() {
        //Spring Security 推荐的密码哈希算法
        return new BCryptPasswordEncoder();
    }

    // 认证管理器(用于后续登录接口)
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }

    //定义安全过滤链,配置请求授权规则
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // ★ 新增:启用 CORS 支持,并指定配置来源
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                // 禁用 CSRF(前后端分离项目不需要)
                .csrf(csrf -> csrf.disable())

                // 设置会话管理为无状态(使用 JWT,不需要 Session)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                // 配置请求授权规则
                .authorizeHttpRequests(auth -> auth
                        // 1. 放行注册和登录接口
                        .requestMatchers("/api/auth/**").permitAll()
                        // 2. 【新增】放行书籍相关的业务接口
//                        .requestMatchers("/api/books/**").permitAll()
                        // 3. 其他所有请求都需要认证
                        .anyRequest().authenticated()
                )

                // 设置自定义 UserDetailsService
                .userDetailsService(customUserDetailsService)
                // ★ 在 UsernamePasswordAuthenticationFilter 之前添加 JWT 过滤器
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
    // ★ 新增:定义 CORS 规则
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        // 允许来自任何源的请求(开发阶段使用 *,生产环境建议指定具体域名)
        configuration.setAllowedOriginPatterns(Arrays.asList("*"));
        // 允许的 HTTP 方法(必须包含 OPTIONS,因为浏览器预检请求会发 OPTIONS)
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        // 允许的请求头
        configuration.setAllowedHeaders(Arrays.asList("*"));
        // 允许携带凭证(如 Cookie,虽然 JWT 不常用,但保持兼容)
        configuration.setAllowCredentials(true);
        // 预检请求的有效期(秒),避免频繁发送 OPTIONS
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        // 对所有路径应用此 CORS 配置
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

配置说明

  • .cors():启用 CORS 并指定自定义规则,解决前端跨域问题。

  • .csrf().disable():前后端分离项目禁用 CSRF。

  • SessionCreationPolicy.STATELESS:无状态会话,使用 JWT。

  • requestMatchers("/api/auth/**").permitAll():放行注册登录接口。

  • addFilterBefore():将 JWT 过滤器置于默认登录过滤器之前。

1.5 编写 JWT 工具类

JWT 工具类负责生成 Token、解析 Token 和验证 Token。

1.5.1 创建 JwtUtil 类
复制代码
package com.example.backend.util;

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

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtUtil {

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

    @Value("${jwt.expiration}")
    private Long expiration;

    /**
     * 将字符串密钥转换为 HMAC-SHA 算法所需的 Key 对象。
     */
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    /**
     * 生成 JWT Token。
     * @param username 用户名
     * @param role 角色(如 "USER" 或 "ADMIN")
     * @return 签名的 Token 字符串
     */
    public String generateToken(String username, String role) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", role);  // 将角色放入自定义声明

        return Jwts.builder()
                .claims(claims)                          // 自定义数据
                .subject(username)                       // 主题(用户名)
                .issuedAt(new Date())                    // 签发时间
                .expiration(new Date(System.currentTimeMillis() + expiration)) // 过期时间
                .signWith(getSigningKey())               // 使用密钥签名
                .compact();                              // 生成紧凑的 Token 字符串
    }

    /**
     * 解析 Token,返回 Claims(包含所有声明)。
     * 如果签名无效或 Token 格式错误,会抛出异常。
     */
    public Claims parseToken(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())   // 设置验证密钥
                .build()
                .parseSignedClaims(token)      // 解析并验证签名
                .getPayload();                 // 获取 Payload 部分
    }

    /**
     * 从 Token 中提取用户名。
     */
    public String getUsernameFromToken(String token) {
        return parseToken(token).getSubject();
    }

    /**
     * 从 Token 中提取角色。
     */
    public String getRoleFromToken(String token) {
        return parseToken(token).get("role", String.class);
    }

    /**
     * 验证 Token 是否有效(签名正确且未过期)。
     */
    public boolean validateToken(String token) {
        try {
            Claims claims = parseToken(token);
            // 检查过期时间是否在当前时间之后
            return !claims.getExpiration().before(new Date());
        } catch (Exception e) {
            // 任何解析异常都视为无效 Token
            return false;
        }
    }
}

常见问题:

  • 签名不匹配 :通常是因为项目重启后密钥改变,或者密钥长度不足。确保 application.yml中的 secret是固定的且足够长。

  • Token 过期validateToken会检查过期时间,过期后需要重新登录。

1.5.2 application.yml 配置
复制代码
jwt:
  secret: MySuperSecretKeyForJWTTokenGeneration2026VeryLongString
  expiration: 86400000   # 24小时

重要:

  • secret至少 32 个字符,越长越安全。切勿泄漏,否则任何人都可以伪造 Token。

  • expiration根据业务需求设置。24 小时对于大多数应用是合理的,过短会导致频繁登录,过长会增加安全风险。

1.6 编写 JWT 认证过滤器(JwtAuthenticationFilter.java)

过滤器是整个认证流程的核心。它拦截每个请求,尝试从请求头中提取 Token,验证后设置认证信息。

复制代码
package com.example.backend.config;

import com.example.backend.model.User;
import com.example.backend.repository.UserRepository;
import com.example.backend.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component  // 告诉 Spring:这是一个组件,请管理它
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;  // 注入 JWT 工具类

    @Autowired
    private UserRepository userRepository;  // 注入用户 Repository

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        // 1. 从请求头中获取 Authorization 字段
        String authHeader = request.getHeader("Authorization");

        // 2. 检查 Authorization 是否存在且以 "Bearer " 开头
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            // 没有 Token,直接放行(后续会被 SecurityConfig 拦截)
            filterChain.doFilter(request, response);
            return;
        }

        // 3. 提取 Token(去掉 "Bearer " 前缀)
        String token = authHeader.substring(7);

        // 4. 从 Token 中提取用户名
        String username = jwtUtil.getUsernameFromToken(token);

        // 5. 检查用户名不为空,且当前还没有认证信息
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            // 6. 验证 Token 是否有效
            if (jwtUtil.validateToken(token)) {

                // 7. 从数据库查询用户信息
                User user = userRepository.findByUsername(username).orElse(null);

                if (user != null) {
                    // 8. 创建认证令牌(包含用户信息和权限)
                    UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(
                                    user,           // 用户对象
                                    null,           // 凭证(密码),这里不需要
                                    user.getAuthorities()  // 权限列表(角色)
                            );

                    // 9. 设置请求详情(IP、Session ID 等)
                    authToken.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request)
                    );

                    // 10. 将认证信息设置到安全上下文中
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        }

        // 11. 放行请求,继续执行后续的过滤器或 Controller
        filterChain.doFilter(request, response);
    }
}

关键点解释:

步骤 代码 说明
1-2 获取 Authorization 头 标准格式为 Bearer <token>,如果不是则以游客身份继续
3 authHeader.substring(7) 去掉 "Bearer "前缀(7个字符),得到纯 Token
4 jwtUtil.getUsernameFromToken(token) 解析 Token 提取用户名,如果 Token 无效会抛出异常,被外层 catch 捕获
5 SecurityContextHolder.getContext().getAuthentication() == null 避免重复认证(例如同一个请求经过多次过滤器)
6 jwtUtil.validateToken(token) 验证签名和有效期
7 userRepository.findByUsername(username) 从数据库获取最新用户信息(如果用户已被删除,则跳过认证)
8 new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()) 创建已认证的令牌,第三个参数是权限列表
10 SecurityContextHolder.getContext().setAuthentication(authToken) 将认证信息存入线程局部变量,后续的 Controller 和 @PreAuthorize可以从中获取用户信息

常见问题:

  • 过滤器未被调用 :确保在 SecurityConfig中通过 addFilterBefore注册了该过滤器。

  • 认证信息未设置 :检查 jwtUtil.validateToken(token)是否返回 true,以及 userRepository.findByUsername(username)是否返回非空用户。

1.7 实现注册与登录接口

1.7.1 创建请求/响应 DTO

AuthRequest.java:

复制代码
package com.example.backend.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class AuthRequest {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在3-20之间")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 40, message = "密码长度必须在6-40之间")
    private String password;

    // getters and setters
}

AuthResponse.java:

复制代码
package com.example.backend.dto;

public class AuthResponse {
    private String token;
    private String username;
    private String role;

    public AuthResponse(String token, String username, String role) {
        this.token = token;
        this.username = username;
        this.role = role;
    }

    // getters and setters
}
1.7.2 创建AuthController
复制代码
package com.example.backend.controller;

import com.example.backend.dto.AuthRequest;
import com.example.backend.dto.AuthResponse;
import com.example.backend.dto.Result;
import com.example.backend.exception.BusinessException;
import com.example.backend.model.Role;
import com.example.backend.model.User;
import com.example.backend.repository.UserRepository;
import com.example.backend.util.JwtUtil;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
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 {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private AuthenticationManager authenticationManager;

    // 注册接口
    @PostMapping("/register")
    public Result<AuthResponse> register(@Valid @RequestBody AuthRequest request) {
        // 1. 检查用户名是否已存在
        if (userRepository.existsByUsername(request.getUsername())) {
            throw new BusinessException(400, "用户名已存在");
        }

        // 2. 创建新用户
        User user = new User();
        user.setUsername(request.getUsername());
        user.setPassword(passwordEncoder.encode(request.getPassword())); // 加密密码
        user.setRole(Role.USER); // 默认角色为普通用户

        // 3. 保存到数据库
        userRepository.save(user);

        // 4. 生成 Token
        String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());

        // 5. 返回响应
        AuthResponse response = new AuthResponse(token, user.getUsername(), user.getRole().name());
        return Result.success(response);
    }

    // 登录接口
    @PostMapping("/login")
    public Result<AuthResponse> login(@Valid @RequestBody AuthRequest request) {
        try {
            // 1. 使用 AuthenticationManager 验证用户名和密码
            Authentication authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            request.getUsername(),
                            request.getPassword()
                    )
            );

            // 2. 认证成功,获取用户信息
            User user = (User) authentication.getPrincipal();

            // 3. 生成 Token
            String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());

            // 4. 返回响应
            AuthResponse response = new AuthResponse(token, user.getUsername(), user.getRole().name());
            return Result.success(response);

        } catch (Exception e) {
            // 认证失败(用户名或密码错误)
            throw new BusinessException(401, "用户名或密码错误");
        }
    }
    // 测试用:注册管理员(生产环境不应开放)
    @PostMapping("/register-admin")
    public Result<AuthResponse> registerAdmin(@Valid @RequestBody AuthRequest request) {
        if (userRepository.existsByUsername(request.getUsername())) {
            throw new BusinessException(400, "用户名已存在");
        }
        User user = new User();
        user.setUsername(request.getUsername());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setRole(Role.ADMIN);  // 设置为管理员
        userRepository.save(user);

        String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
        AuthResponse response = new AuthResponse(token, user.getUsername(), user.getRole().name());
        return Result.success(response);
    }
}

登录接口详解:

  • authenticationManager.authenticate(...)会依次执行:

    1. 调用 CustomUserDetailsService.loadUserByUsername()加载用户。

    2. 使用 PasswordEncoder.matches()比对密码。

    3. 如果匹配,返回已认证的 Authentication对象;否则抛出异常。

  • 我们捕获所有异常,统一返回 401,避免泄露具体是用户名错误还是密码错误(安全最佳实践)。

1.8 角色权限控制

1.8.1 启用方法安全

确保 SecurityConfig类上有 @EnableMethodSecurity注解,这样 @PreAuthorize才会生效。

1.8.2 在 Controller 方法上添加权限注解

BookController的方法上添加 @PreAuthorize

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

    // 所有角色都可以查看书籍
    @GetMapping("/books")
    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    public Result<List<BookResponse>> getAllBooks(...) { ... }

    // 只有管理员可以新增、修改、删除
    @PostMapping("/books")
    @PreAuthorize("hasRole('ADMIN')")
    public Result<BookResponse> addBook(...) { ... }

    @PutMapping("/books/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public Result<BookResponse> updateBook(...) { ... }

    @DeleteMapping("/books/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public Result<Void> deleteBook(...) { ... }
}

注解说明:

  • @PreAuthorize("hasRole('ADMIN')"):在方法执行前检查当前用户是否拥有 ROLE_ADMIN权限。

  • @PreAuthorize("hasAnyRole('USER', 'ADMIN')"):检查是否拥有 ROLE_USERROLE_ADMIN之一。

  • 如果权限不足,Spring Security 会抛出 AccessDeniedException,最终返回 403 Forbidden。

二、前端项目初始化

2.1 创建 Vue3 + Vite 项目

复制代码
npm create vite@latest frontend -- --template vue
cd frontend
npm install
npm install axios vue-router@4

2.2 配置开发服务器代理(vite.config.js

复制代码
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8085',
        changeOrigin: true
      }
    }
  }
})

2.3 创建 axios 实例与拦截器(src/api/http.js

复制代码
import axios from 'axios'

const http = axios.create({
  baseURL: '/api',
  timeout: 10000
})

http.interceptors.request.use(config => {
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

http.interceptors.response.use(
  response => response.data,
  error => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token')
      window.location.hash = '#/login'
    }
    return Promise.reject(error)
  }
)

export default http

2.4 配置路由守卫(src/router/index.js

复制代码
import { createRouter, createWebHashHistory } from 'vue-router'

const routes = [
  { path: '/login', component: () => import('../views/Login.vue') },
  { path: '/books', component: () => import('../views/BookList.vue') }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  if (to.path !== '/login' && !token) {
    next('/login')
  } else {
    next()
  }
})

export default router

三、测试验证

3.1 注册

复制代码
POST http://localhost:8085/api/auth/register
{
  "username": "testuser",
  "password": "123456"
}

返回 Token,状态码 200。

3.2 登录

复制代码
POST http://localhost:8085/api/auth/login
{
  "username": "testuser",
  "password": "123456"
}

返回新的 Token。

3.3 访问受保护接口(携带 Token)

复制代码
GET http://localhost:8085/api/books
Authorization: Bearer <token>

返回书籍列表。

3.4 权限不足

使用普通用户的 Token 调用 POST /api/books,返回 403。

3.5 前端测试

启动前端开发服务器,打开 http://localhost:3000,应自动跳转到登录页。输入正确的用户名密码后,跳转到书籍列表页,并能正常展示数据。

四、遇到的问题与解决

4.1 跨域问题

现象 :前端请求后端时报 blocked by CORS policy

原因:后端未配置 CORS,或 Spring Security 的过滤器链拦截了预检请求。

解决 :在 SecurityConfig中添加 .cors()CorsConfigurationSourceBean,允许所有来源和方法。

4.2 JWT 签名不匹配

现象 :日志显示 JWT signature does not match locally computed signature

原因 :项目重启后 jwt.secret被修改,或密钥长度不足。

解决 :确保 application.yml中的密钥固定且长度 ≥ 32 字符,重新登录获取新 Token。

4.3 403 Forbidden

现象:携带有效 Token 但仍返回 403。

原因 :角色名称大小写不匹配。数据库存储了 user(小写),而 @PreAuthorize中写的是 hasRole('USER')(大写)。

解决 :统一使用大写,数据库更新为 USERADMIN

4.4 前端代理不生效

现象 :前端请求 /api/books返回 404 或无法连接。

原因vite.config.js配置后未重启开发服务器。

解决 :修改配置后必须重启 npm run dev

五、总结

本周我完成了从后端到前端的完整认证授权体系搭建:

  • 后端实现了基于 Spring Security 和 JWT 的用户注册、登录、Token 验证、角色权限控制。

  • 前端初始化了 Vue3 + Vite 项目,配置了 axios 拦截器自动携带 Token,并设置了路由守卫保护页面。

  • 解决了跨域、签名不匹配、角色权限等实际问题。

现在,我的项目已经具备了生产级的认证能力,为后续的业务功能开发打下了坚实的基础。下一步,我将进入前后端联调阶段,实现完整的书籍管理功能。

源码backend4 frontend

相关推荐
用户298698530141 小时前
Python 实现 Excel 到 ODS、XPS、PostScript 及 PDF/A-1b 的格式转换
后端·python·excel
爱勇宝2 小时前
AI 时代,更稀缺的是「提出好问题」还是「判断好答案」?
前端·人工智能·后端
逝水无殇2 小时前
C# 正则表达式详解
开发语言·后端·正则表达式·c#
老孙讲技术2 小时前
集成商选型笔记:原生 OpenSDK(低延迟)vs Web 轻应用(7 天上线)怎么选
后端
大鸡腿同学2 小时前
AI 智能剪辑
后端
运维行者_2 小时前
广域网性能监控:分布式IT架构下的链路质量保障
开发语言·网络·分布式·后端·架构·数据库架构
灯澜忆梦2 小时前
GO---map函数
开发语言·前端·后端·golang
QQ_21696290962 小时前
【项目编号:project05966】SpringBoot/Vue 电影推荐系统:用户端推荐、电影资讯与后台数据管理全流程
vue.js·spring boot·后端
吴声子夜歌3 小时前
Redis 6.x——整合SpringBoot
数据库·spring boot·redis