Spring Security 在前后端分离项目中的应用

205 阅读5分钟

在这篇博客中,我们将详细讲解如何在前后端分离的架构中使用 Spring Security,并结合 JWT 实现用户认证。为了让代码更具可读性,我们将在代码中添加详细的注释,帮助理解其工作原理。

1. 项目准备

在后端的 pom.xml 文件中,引入 Spring Security、JWT 以及其他必要的依赖:

<dependencies>
    <!-- Spring Boot Web 依赖,构建 REST API -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Security 依赖,用于用户认证和授权 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- JWT 依赖,用于生成和解析 JSON Web Token -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>

    <!-- MySQL 依赖,用于数据库连接 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>

    <!-- Spring Data JPA 依赖,用于数据库操作 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
</dependencies>

2. 数据库表设计

首先,我们需要创建用户和角色表,用于存储用户的基本信息以及权限信息:

-- 用户表,用于存储用户信息
CREATE TABLE users (
  id BIGINT NOT NULL AUTO_INCREMENT,
  username VARCHAR(50) NOT NULL,
  password VARCHAR(100) NOT NULL,
  enabled TINYINT NOT NULL DEFAULT 1,
  PRIMARY KEY (id)
);

-- 角色表,用于存储用户角色
CREATE TABLE roles (
  id BIGINT NOT NULL AUTO_INCREMENT,
  role_name VARCHAR(50) NOT NULL,
  PRIMARY KEY (id)
);

-- 用户与角色的关联表
CREATE TABLE user_roles (
  user_id BIGINT NOT NULL,
  role_id BIGINT NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id),
  FOREIGN KEY (role_id) REFERENCES roles(id)
);

3. 编写用户服务

在 Spring Security 中,用户认证是基于 UserDetails 进行的。我们需要实现 UserDetailsService 接口,从数据库加载用户信息。

1. User 实体类
@Entity
@Table(name = "users")  // 映射数据库中的users表
public class User implements UserDetails {  // 实现 UserDetails 接口
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String password;
    private boolean enabled;

    @ManyToMany(fetch = FetchType.EAGER)  // 定义多对多关系,立即加载角色信息
    @JoinTable(
      name = "user_roles",  // 关联表名称
      joinColumns = @JoinColumn(name = "user_id"),
      inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles;

    // 获取用户的角色权限信息
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
            .map(role -> new SimpleGrantedAuthority(role.getRoleName()))  // 将角色名转换为 GrantedAuthority
            .collect(Collectors.toSet());
    }

    // 实现 UserDetails 接口的其他必要方法
    @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 enabled;  // 用户是否启用
    }
}

  1. Role 实体类
@Entity
@Table(name = "roles")  // 映射数据库中的roles表
public class Role {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String roleName;  // 角色名称

    // getter/setter 方法
    public String getRoleName() {
        return roleName;
    }

    public void setRoleName(String roleName) {
        this.roleName = roleName;
    }
}

3. 编写 UserDetailsServiceImpl

我们通过 UserDetailsServiceImpl 从数据库中加载用户信息,并实现用户认证逻辑。

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;

    // 根据用户名加载用户信息
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("User not found");  // 如果找不到用户,抛出异常
        }
        return user;  // 返回用户信息
    }
}

4. 使用 JWT 进行认证

1. JWT 工具类

JWT 用于在用户登录后生成一个 Token,并在后续请求中通过该 Token 验证用户身份。

@Component
public class JwtUtils {
    private static final String SECRET_KEY = "mySecretKey";  // 用于签名的秘钥

    // 生成 JWT Token
    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
            .setSubject(userDetails.getUsername())  // 将用户名作为 Token 的主体
            .setIssuedAt(new Date())  // 设置 Token 签发时间
            .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10))  // 设置 Token 10小时有效期
            .signWith(SignatureAlgorithm.HS256, SECRET_KEY)  // 使用 HS256 算法进行签名
            .compact();
    }

    // 从 Token 中提取用户名
    public String extractUsername(String token) {
        return Jwts.parser()
            .setSigningKey(SECRET_KEY)  // 使用签名秘钥进行验证
            .parseClaimsJws(token)  // 解析 Token
            .getBody()
            .getSubject();  // 返回 Token 中的主体信息(即用户名)
    }

    // 验证 Token 是否有效
    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);  // 提取 Token 中的用户名
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));  // 验证用户名和 Token 是否过期
    }

    // 检查 Token 是否过期
    private boolean isTokenExpired(String token) {
        return Jwts.parser()
            .setSigningKey(SECRET_KEY)
            .parseClaimsJws(token)
            .getBody()
            .getExpiration()
            .before(new Date());  // 判断 Token 过期时间是否在当前时间之前
    }
}

2. 编写 JwtAuthenticationFilter

JwtAuthenticationFilter 是一个过滤器,用于拦截请求并验证 JWT Token。

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtils jwtUtils;

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    // 过滤器内部逻辑
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

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

        String username = null;
        String token = null;

        // 检查 Authorization 头部是否以 "Bearer " 开头
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            token = authorizationHeader.substring(7);  // 提取 Token
            username = jwtUtils.extractUsername(token);  // 从 Token 中提取用户名
        }

        // 如果用户名不为空,并且用户没有被认证
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);  // 加载用户信息

            // 验证 Token 是否有效
            if (jwtUtils.validateToken(token, userDetails)) {
                // 创建认证对象,并将其存储到 SecurityContext 中
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        // 继续执行过滤器链
        filterChain.doFilter(request, response);
    }
}

5. 配置 Spring Security

最后,我们需要配置 Spring Security,禁用默认的表单登录,并配置 JWT 过滤器。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    // 配置 Spring Security
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()  // 禁用 CSRF
            .authorizeRequests()
            .antMatchers("/login").permitAll()  // 登录接口允许匿名访问
            .anyRequest().authenticated();  // 其他所有请求都需要认证

        // 在用户名密码认证过滤器之前添加 JWT 过滤器
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }

    // 配置认证管理器,使用自定义的 UserDetailsService 和密码编码器
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder());
    }

    // 配置密码编码器,使用 BCrypt 算法
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

6. 用户登录接口

创建一个登录接口,用户提交用户名和密码后,后端验证成功后返回 JWT Token。

@RestController
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtUtils jwtUtils;

    // 用户登录接口
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody AuthRequest authRequest) {
        try {
            // 使用 Spring Security 的 AuthenticationManager 进行用户认证
            authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(authRequest.getUsername(), authRequest.getPassword())
            );
        } catch (BadCredentialsException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");  // 认证失败时返回 401
        }

        // 认证成功后,生成 JWT Token
        final UserDetails userDetails = userDetailsService.loadUserByUsername(authRequest.getUsername());
        final String jwt = jwtUtils.generateToken(userDetails);

        // 返回 JWT Token 给客户端
        return ResponseEntity.ok(new AuthResponse(jwt));
    }
}

7. 前端实现

在前端项目中,当用户登录成功后,后端返回的 JWT Token 需要存储在前端的 localStoragesessionStorage 中。每次调用后端 API 时,前端需将该 Token 放在请求头的 Authorization 字段中。

axios.interceptors.request.use(config => {
    const token = localStorage.getItem('token');  // 从 localStorage 中获取 JWT Token
    if (token) {
        config.headers['Authorization'] = 'Bearer ' + token;  // 将 Token 添加到请求头中
    }
    return config;
}, error => {
    return Promise.reject(error);
});