使用 Spring Boot 3 和 Spring Security 6 进行 JWT 认证和授权

外网翻译文章,原文链接

在我学习 Spring Security 的过程中,我曾经思考过是否有其他开发者也有类似的经历。那些努力理解 Spring Security 组件内部工作原理的人,或者那些希望通过示例代码来学习并打算基于这个基础开发自己项目的人等等。因此,我决定写下这篇文章,希望它能在某种程度上有所帮助。 😃这篇文章旨在展示一个基本的 Web 应用程序,其API由 Spring Security 进行保护。与深入介绍 Spring Security 概念不同,因为它们已经在官方网站上可以找到,我将专注于在构建项目组件时提供全面的解释。文章末尾,你将找到一个包含功能代码以供参考的GitHub存储库链接。让我们开始吧!

应用架构

设想

  • 用户向服务发出请求,希望创建一个账户。
  • 用户提交请求给服务,以验证他们的账户。
  • 用户提交一个请求给服务,以验证他们的账户。

注册

注册流程非常简单。一个值得注意的组件是 JwtService,这是一个用于处理 JWT 操作的自定义服务。更多的实现细节可以在下面的代码部分找到。

  1. 流程从用户提交请求开始。然后,从请求数据中生成一个用户对象,其中密码被使用 PasswordEncoder 进行编码。
  2. 用户对象使用 UserRepository 存储在数据库中,该存储库利用了 Spring Data JPA。 然后调用 JwtService 生成用户对象的 JWT。 * 3. JWT 封装在 JSON 响应中,随后返回给用户。

重要的是要记住,我们必须告诉 Spring 使用在应用程序中使用的特定密码编码器,这里我们使用的是 PasswordEncoder。这个信息对于Spring来正确验证用户(通过解码他们的密码)是必要的。在我们配置 AuthenticationProvider 部分时,我将详细说明这一点。

登录
  1. 该流程始于用户向服务发送登录请求。然后,使用提供的用户名和密码生成一个称为UsernamePasswordAuthenticationTokenAuthentication对象。

  2. AuthenticationManager 负责验证 Authentication 对象,处理所有必要的任务。如果用户名或密码不正确,将会抛出异常,并向用户返回 HTTP 状态码 403 的响应。

  3. 成功身份验证后,将尝试从数据库中检索用户。如果数据库中不存在该用户,则向该用户发送具有HTTP状态403的响应。但是,由于我们已经通过了第2步(身份验证),所以这一步并不重要,因为用户应该已经在数据库中了。

  4. 一旦我们获取了用户信息,我们调用 JwtService 来生成 JWT。

  5. 然后,JWT 被封装在一个 JSON 响应中,并返回给用户。

在这个过程中引入了两个新概念,我将为每个概念提供简要解释。
1. UsernamePasswordAuthenticationToken:
一种可以从提交的用户名和密码创建的 Authentication 对象类型。
2. AuthenticationManager:
处理认证对象并将为我们执行所有的认证工作的过程。

访问资源

这个流程受到 Spring Security 的保护,让我们按如下方式检查其流程。

  1. 这个流程从用户向服务发送请求开始。请求首先会被 JwtAuthenticationFilter拦截,它是集成到 SecurityFilterChain 中的自定义过滤器。

  2. 由于 API 受到保护,如果缺少 JWT,则会向用户发送一个带有 HTTP 状态码 403 的响应。

  3. 当接收到现有的 JWT 时,会调用 JwtService 来从 JWT 中提取 userEmail。如果无法提取 userEmail,将向用户发送一个带有 HTTP 状态码 403 的响应。

  4. 如果能够提取出 userEmail,将使用它通过 UserDetailsService 查询用户的认证和授权信息。

  5. 如果用户的认证和授权信息在数据库中不存在,将向用户发送一个带有 HTTP 状态码 403 的响应。

  6. 如果JWT已过期,将向用户发送一个带有HTTP状态码403的响应。

  7. 在成功认证之后,用户的详细信息被封装在一个 UsernamePasswordAuthenticationToken 对象中,并存储在 SecurityContextHolder 中。

  8. Spring Security的授权过程会自动触发。

  9. 请求被分发到控制器,然后成功的JSON响应被返回给用户。

这个过程稍微复杂一些,涉及一些新概念。让我们更详细地深入了解它们:
1. SecurityFilterChain: 一个过滤器链,可以与HttpServletRequest进行匹配,以决定它是否适用于该请求。
2. SecurityContextHolder:  这是Spring Security存储已经认证用户详细信息的地方。Spring Security会利用这些信息进行授权。
3. UserDetailsService:  用于获取特定用户数据的服务。
4. Authorization Architecture

源代码演示

创建一个名为"security"的Spring Boot项目,并在下面的POM文件中添加所需的依赖。对于一些较难理解的部分,我将提供详细的解释和参考链接。但对于其余的部分,您可以在GitHub上找到相关的信息。

https://github.com/buingoctruong/springboot3-springsecurity6-jwt/blob/master/pom.xml

数据库配置

H2是一个知名的内存数据库,在进行快速概念验证时是一个不错的选择。Spring Boot为H2提供了便捷的交互方式,我们不需要像安装数据库、设置模式、创建表格、填充数据等操作。 😎

为了连接到H2数据库,我们需要在application.yaml文件中添加相应的属性。 (详细的属性解释已包含在注释中).

spring:
  jpa:
    # Provide database platform that is being used
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      # New database is created when app starts and destroyed when app stops
      ddl-auto: create-drop
    # Show sql when spring data jpa performs query
    show-sql: true
    properties:
      hibernate:
        # Format queries
        format_sql: true
  datasource:
    # URL connection to database (spring-security is database name)
    url: jdbc:h2:mem:spring-security
    # H2 SQL driver dependency
    driver-class-name: org.h2.Driver
    username: root
    password: 12345

完整的application.yaml文件: https://github.com/buingoctruong/springboot3-springsecurity6-jwt/blob/master/src/main/resources/application.yaml

用户实体

认证和授权涉及与用户相关的事项。让我们创建我们的User类(对于这个演示,我们应该保持User模型简单,但真实世界的User模型可能会更复杂)。

public enum Role {USER,ADMIN}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "_user")
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String firstName;
    private String lastName;
    private String email;
    private String password;
    @Enumerated(EnumType.STRING)
    private Role role;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role.name()));
    }

    @Override
    public String getUsername() {
        // email in our case
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

在我们的应用程序中使用Spring Security进行身份验证和授权时,必须向Spring Security API提供用户特定的数据,并在身份验证过程中使用它。这些用户特定的数据封装在UserDetails对象中。UserDetails是一个包含各种方法的接口。为了简化与Spring Security的集成,我正在实现UserDetails接口,就像上面的代码片段中所演示的一样。(有关更详细的解释,请参考安全配置部分)。

在此参考链接中探索有关UserDetails的其他信息: UserDetails

User Repository

https://github.com/buingoctruong/springboot3-springsecurity6-jwt/blob/master/src/main/java/com/truongbn/security/repository/UserRepository.java

User Service
public interface UserService {
    UserDetailsService userDetailsService();
}
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
    private final UserRepository userRepository;
    @Override
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) {
                return userRepository.findByEmail(username)
                        .orElseThrow(() -> new UsernameNotFoundException("User not found"));
            }
        };
    }
}

UserDetailsService是一个接口,用于检索用户的身份验证和授权信息。它只有一个方法loadUserByUsername(),可以实现它来向Spring Security API提供用户信息。在执行身份验证过程时,DaoAuthenticationProvider会利用这个方法来加载用户信息。

JWT Service
public interface JwtService {
    String extractUserName(String token);

    String generateToken(UserDetails userDetails);

    boolean isTokenValid(String token, UserDetails userDetails);
}
@Service
public class JwtServiceImpl implements JwtService {
    @Value("${token.signing.key}")
    private String jwtSigningKey;
    @Override
    public String extractUserName(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    @Override
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    @Override
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String userName = extractUserName(token);
        return (userName.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolvers) {
        final Claims claims = extractAllClaims(token);
        return claimsResolvers.apply(claims);
    }

    private String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder().setClaims(extraClaims).setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256).compact();
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token)
                .getBody();
    }

    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSigningKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

对于那些对JSON Web Token(JWT)不熟悉的人,您可以参考此链接来了解更多关于它的信息:Json Web Token

Authentication Service
public interface AuthenticationService {
    JwtAuthenticationResponse signup(SignUpRequest request);

    JwtAuthenticationResponse signin(SigninRequest request);
}
@Service
@RequiredArgsConstructor
public class AuthenticationServiceImpl implements AuthenticationService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;
    @Override
    public JwtAuthenticationResponse signup(SignUpRequest request) {
        var user = User.builder().firstName(request.getFirstName()).lastName(request.getLastName())
                .email(request.getEmail()).password(passwordEncoder.encode(request.getPassword()))
                .role(Role.USER).build();
        userRepository.save(user);
        var jwt = jwtService.generateToken(user);
        return JwtAuthenticationResponse.builder().token(jwt).build();
    }

    @Override
    public JwtAuthenticationResponse signin(SigninRequest request) {
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()));
        var user = userRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));
        var jwt = jwtService.generateToken(user);
        return JwtAuthenticationResponse.builder().token(jwt).build();
    }
}

有关Sign Up和Sign In过程的更多详细信息,请参考上面的应用程序架构部分提供的图表。

自定义Filter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    private final UserService userService;
    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
            throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;
        if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWith(authHeader, "Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        jwt = authHeader.substring(7);
        userEmail = jwtService.extractUserName(jwt);
        if (StringUtils.isNotEmpty(userEmail)
                && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userService.userDetailsService()
                    .loadUserByUsername(userEmail);
            if (jwtService.isTokenValid(jwt, userDetails)) {
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                context.setAuthentication(authToken);
                SecurityContextHolder.setContext(context);
            }
        }
        filterChain.doFilter(request, response);
    }
}

自定义过滤器扩展了OncePerRequestFilter,以确保我们的过滤器每个请求只被调用一次。它定义了以下功能:

  • 通过解析Bearer Token来检索userEmail,然后在数据库中搜索相应的用户信息。

  • 验证JWT的真实性。

  • 使用提供的用户名和密码生成一个Authentication对象,然后将其存储在SecurityContextHolder中。

有关OncePerRequestFilter的详细信息,请参阅此链接: OncePerRequestFilter

Web Security设置
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserService userService;
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(request -> request.requestMatchers("/api/v1/auth/**")
                        .permitAll().anyRequest().authenticated())
                .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS))
                .authenticationProvider(authenticationProvider()).addFilterBefore(
                        jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

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

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

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

@EnableWebSecurity注解启用了Spring Web安全,并配置了以下方面:(请提供配置的详细说明)。

  • 定义authenticationProviderbean,这个bean在身份验证过程中被使用。
  • 定义passwordEncoder bean,Spring将在解码密码时使用该bean。
  • 定义authentication manager bean。
  • 定义Security filter chain bean,并设置一些规则,如:
    • 白名单请求 {/api/v1/auth/**},任何其他请求应进行身份验证。
    • 无状态管理,这意味着我们不应该存储身份验证状态。
    • 添加一种数据访问对象提供程序 - DaoAuthenticationProvider,负责获取用户信息和编码/解码密码。
  • UsernamePasswordAuthenticationFilter之前添加JwtAuthenticationFilter,因为我们需要在JwtAuthenticationFilter中提取用户名和密码,然后将它们更新到SecurityContextHolder中。

这里介绍了两个新概念。欲了解更多信息,请参考所提供的参考链接:
DaoAuthenticationProvider
Usernamepasswordauthenticationfilter

Cotroller

https://github.com/buingoctruong/springboot3-springsecurity6-jwt/blob/master/src/main/java/com/truongbn/security/controller/AuthenticationController.java

https://github.com/buingoctruong/springboot3-springsecurity6-jwt/blob/master/src/main/java/com/truongbn/security/controller/AuthorizationController.java

是时候测试我们做了什么了

运行应用程序(它应该在8080端口上运行),然后打开Postman。

  • 再次访问Sign-In API URL,这次在请求体中包含一个有效的密码。然后,检查响应:身份验证过程将成功,并且将返回用户令牌。

  • 访问http://localhost:8080/api/v1/resource,但不包括所需的授权信息。由于此API受保护,没有令牌将无法访问资源。请验证响应以获取进一步的详细信息。

  • 复制在注册过程中生成的用户令牌,并将其作为授权头部(Bearer Token类型)包含在请求中。然后发送另一个请求到Resource API URL,并检查响应:现在我们应该成功访问所需的资源。 😍

我们刚刚目睹了Spring Security在实际应用中的简要演示,希望它能按预期运行!

Spring Security是否具有挑战性?绝对具有挑战性!最初理解其内部工作原理可能会相当困难。就我个人而言,即使完成了这篇文章,我仍然没有完全掌握它 😆。然而,请不要放弃,继续努力 💪。请记住,许多人愿意互相帮助,所以请不要犹豫在评论部分分享您的想法或疑虑。

完成的源代码可以在此GitHub存储库中找到:https://github.com/buingoctruong/springboot3-springsecurity6-jwt

愿您学习愉快!

再见!

Spring Boot

发表回复

您的电子邮箱地址不会被公开。