个性化阅读
专注于IT技术分析

使用Spring Boot进行OAuth2和JWT REST保护

点击下载

本文概述

本文是有关如何使用Spring Boot和Maven设置服务器端实现JSON Web令牌(JWT)-OAuth2授权框架的指南。

建议你初步了解OAuth2, 并可以阅读上面链接的草稿或在网络上搜索此类内容, 以获取有用的信息。

OAuth2是一个授权框架, 它取代了它的第一版OAuth(创建于2006年)。它定义了客户端与一个或多个HTTP服务之间的授权流, 以便获得对受保护资源的访问。

OAuth2定义了以下服务器端角色:

  • 资源所有者:负责控制资源访问的服务
  • 资源服务器:实际提供资源的服务
  • 授权服务器:服务处理授权过程, 充当客户端和资源所有者之间的中间人

JSON Web令牌或JWT是表示要在两方之间转移的声明的规范。声明被编码为JSON对象, 用作加密结构的有效负载, 从而使声明可以进行数字签名或加密。

包含的结构可以是JSON Web签名(JWS)或JSON Web加密(JWE)。

可以选择JWT作为OAuth2协议内部使用的访问和刷新令牌的格式。

由于以下功能, OAuth2和JWT在过去几年中获得了极大的普及:

  • 为无状态REST协议提供无状态授权系统
  • 非常适合微服务架构, 其中多个资源服务器可以共享一个授权服务器
  • 由于JSON格式, 令牌内容易于在客户端进行管理

但是, 如果以下注意事项对于项目很重要, 则OAuth2和JWT并不总是最佳选择:

  • 无状态协议不允许服务器端进行访问撤销
  • 令牌的固定生命周期为管理长时间运行的会话增加了额外的复杂性, 而又不损害安全性(例如刷新令牌)
  • 客户端上令牌的安全存储要求

预期协议流

尽管OAuth2的主要功能之一是引入了一个授权层, 以便将授权过程与资源所有者分开, 但为简单起见, 本文的结果是构建了一个模拟所有资源所有者, 授权服务器和应用程序的应用程序。资源服务器角色。因此, 通信将仅在两个实体(服务器和客户端)之间流动。

这种简化应有助于将重点放在本文的目的上, 即在Spring Boot的环境中设置这样的系统。

简化的流程如下所述:

  1. 使用密码授权授予将授权请求从客户端发送到服务器(充当资源所有者)
  2. 访问令牌返回给客户端(以及刷新令牌)
  3. 然后, 在每个受保护的资源访问请求中, 访问令牌都从客户端发送到服务器(充当资源服务器)
  4. 服务器响应所需的受保护资源
认证流程图

Spring Security和Spring Boot

首先, 简要介绍为此项目选择的技术堆栈。

首选的项目管理工具是Maven, 但是由于项目的简单性, 因此切换到Gradle等其他工具应该不难。

在本文的续篇中, 我们仅关注Spring Security方面, 但是所有代码摘录均取材于功能完备的服务器端应用程序, 该源代码可在公共存储库中使用, 而客户端则使用其REST资源。

Spring Security是一个框架, 可为基于Spring的应用程序提供几乎声明性的安全服务。它的根源于Spring的第一期, 由于涵盖了许多不同的安全技术, 因此按一组模块进行组织。

让我们快速看一下Spring Security架构(可以在这里找到更详细的指南)。

安全性主要是关于身份验证, 即身份验证和授权, 对资源的访问权限的授予。

Spring Security支持由第三方提供或本机实现的多种身份验证模型。可以在这里找到列表。

关于授权, 确定了三个主要领域:

  1. Web请求授权
  2. 方法级别授权
  3. 访问域对象实例授权

认证方式

基本接口是AuthenticationManager, 负责提供身份验证方法。 UserDetailsS​​ervice是与用户信息收集相关的接口, 对于标准JDBC或LDAP方法, 可以直接实现该接口或在内部使用该信息。

授权书

主界面是AccessDecisionManager;上面列出的所有三个区域的实现都委托给AccessDecisionVoter链。后一个接口的每个实例都表示身份验证(用户身份, 命名为主体), 资源和ConfigAttribute集合之间的关联, 该规则集描述了资源所有者如何允许访问资源本身, 可能是通过使用用户角色。

使用Servlet过滤器链中的上述基本元素来实现Web应用程序的安全性, 并且将WebSecurityConfigurerAdapter类作为表示资源访问规则的声明性方式公开。

首先通过存在@EnableGlobalMethodSecurity(securedEnabled = true)批注来启用方法安全性, 然后通过使用一组专用批注将其应用于每个要保护的方法, 例如@ Secured, @ PreAuthorize和@PostAuthorize。

Spring Boot在所有这些基础上添加了经过认真考虑的应用程序配置和第三方库, 以简化开发并保持高质量标准。

带有Spring Boot的JWT OAuth2

现在, 让我们继续讨论最初的问题, 以设置通过Spring Boot实现OAuth2和JWT的应用程序。

尽管Java世界中存在多个服务器端OAuth2库(可以在此处找到列表), 但是基于Spring的实现是自然的选择, 因为我们希望可以将其很好地集成到Spring Security架构中, 因此避免了处理大量内容的需要底层细节。

Maven借助Spring Boot来处理所有与安全性相关的库依赖关系, 这是Maven配置文件pom.xml中唯一需要显式版本的组件(即Maven会自动选择最新的库来推断库的版本)版本与插入的Spring Boot版本兼容)。

在maven的配置文件pom.xml中摘录, 其中包含与Spring Boot安全性相关的依赖关系:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security.oauth.boot</groupId>
        <artifactId>spring-security-oauth2-autoconfigure</artifactId>
        <version>2.1.0.RELEASE</version>
    </dependency>

该应用程序既充当OAuth2授权服务器/资源所有者, 又充当资源服务器。

受保护的资源(作为资源服务器)发布在/ api /路径下, 而身份验证路径(作为资源所有者/授权服务器)按照建议的默认值映射到/ oauth / token。

应用程序的结构:

  • 包含安全配置的安全软件包
  • 包含错误处理的错误包
  • 用户, 用于REST资源(包括模型, 存储库和控制器)的lee软件包

接下来的段落介绍了上述三个OAuth2角色中每个角色的配置。相关类在安全包中:

  • OAuthConfiguration, 扩展AuthorizationServerConfigurerAdapter
  • ResourceServerConfiguration, 扩展了ResourceServerConfigurerAdapter
  • ServerSecurityConfig, 扩展了WebSecurityConfigurerAdapter
  • UserService, 实现UserDetailsS​​ervice

资源所有者和授权服务器的设置

通过@EnableAuthorizationServer批注启用授权服务器行为。它的配置与与资源所有者行为有关的配置合并在一起, 并且两者都包含在类AuthorizationServerConfigurerAdapter中。

此处应用的配置与以下内容有关:

  • 客户端访问(使用ClientDetailsS​​erviceConfigurer)
    • 使用inMemory或jdbc方法选择使用基于内存的存储或基于JDBC的客户端详细信息
    • 使用clientId和clientSecret(使用所选的PasswordEncoder bean编码)属性进行客户端的基本身份验证
    • 使用accessTokenValiditySeconds和refreshTokenValiditySeconds属性访问和刷新令牌的有效时间
    • 使用authorizedGrantTypes属性允许的授予类型
    • 使用scopes方法定义访问范围
    • 识别客户可访问的资源
  • 授权服务器端点(使用AuthorizationServerEndpointsConfigurer)
    • 使用accessTokenConverter定义JWT令牌的使用
    • 定义使用UserDetailsS​​ervice和AuthenticationManager接口执行身份验证(作为资源所有者)
package net.reliqs.gleeometer.security;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;

@Configuration
@EnableAuthorizationServer
public class OAuthConfiguration extends AuthorizationServerConfigurerAdapter {

   private final AuthenticationManager authenticationManager;

   private final PasswordEncoder passwordEncoder;

   private final UserDetailsService userService;

   @Value("${jwt.clientId:glee-o-meter}")
   private String clientId;

   @Value("${jwt.client-secret:secret}")
   private String clientSecret;

   @Value("${jwt.signing-key:123}")
   private String jwtSigningKey;

   @Value("${jwt.accessTokenValidititySeconds:43200}") // 12 hours
   private int accessTokenValiditySeconds;

   @Value("${jwt.authorizedGrantTypes:password, authorization_code, refresh_token}")
   private String[] authorizedGrantTypes;

   @Value("${jwt.refreshTokenValiditySeconds:2592000}") // 30 days
   private int refreshTokenValiditySeconds;

   public OAuthConfiguration(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, UserDetailsService userService) {
       this.authenticationManager = authenticationManager;
       this.passwordEncoder = passwordEncoder;
       this.userService = userService;
   }

   @Override
   public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
       clients.inMemory()
               .withClient(clientId)
               .secret(passwordEncoder.encode(clientSecret))
               .accessTokenValiditySeconds(accessTokenValiditySeconds)
               .refreshTokenValiditySeconds(refreshTokenValiditySeconds)
               .authorizedGrantTypes(authorizedGrantTypes)
               .scopes("read", "write")
               .resourceIds("api");
   }

   @Override
   public void configure(final AuthorizationServerEndpointsConfigurer endpoints) {
       endpoints
               .accessTokenConverter(accessTokenConverter())
               .userDetailsService(userService)
               .authenticationManager(authenticationManager);
   }

   @Bean
   JwtAccessTokenConverter accessTokenConverter() {
       JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
       return converter;
   }

}

下一节描述了适用于资源服务器的配置。

资源服务器的设置

通过使用@EnableResourceServer批注启用资源服务器行为, 并且其配置包含在类ResourceServerConfiguration中。

这里唯一需要的配置是资源标识的定义, 以匹配上一类中定义的客户端访问权限。

package net.reliqs.gleeometer.security;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;

@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

   @Override
   public void configure(ResourceServerSecurityConfigurer resources) {
       resources.resourceId("api");
   }

}

最后一个配置元素是有关Web应用程序安全性的定义。

网络安全设置

Spring Web安全配置包含在ServerSecurityConfig类中, 该类通过使用@EnableWebSecurity批注启用。 @EnableGlobalMethodSecurity允​​许在方法级别指定安全性。设置其属性proxyTargetClass是为了使其适用于RestController的方法, 因为控制器通常是类, 而不实现任何接口。

它定义了以下内容:

  • 要使用的身份验证提供程序, 定义了bean的authenticationProvider
  • 要使用的密码编码器, 定义了bean的passwordEncoder
  • 认证管理器bean
  • 使用HttpSecurity的发布路径的安全性配置
  • 使用自定义AuthenticationEntryPoint来处理标准Spring REST错误处理程序ResponseEntityExceptionHandler之外的错误消息
package net.reliqs.gleeometer.security;

import net.reliqs.gleeometer.errors.CustomAccessDeniedHandler;
import net.reliqs.gleeometer.errors.CustomAuthenticationEntryPoint;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true)
public class ServerSecurityConfig extends WebSecurityConfigurerAdapter {

   private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

   private final UserDetailsService userDetailsService;

   public ServerSecurityConfig(CustomAuthenticationEntryPoint customAuthenticationEntryPoint, @Qualifier("userService")
           UserDetailsService userDetailsService) {
       this.customAuthenticationEntryPoint = customAuthenticationEntryPoint;
       this.userDetailsService = userDetailsService;
   }

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

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

   @Bean
   @Override
   public AuthenticationManager authenticationManagerBean() throws Exception {
       return super.authenticationManagerBean();
   }

   @Override
   protected void configure(HttpSecurity http) throws Exception {
       http
               .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
               .and()
               .authorizeRequests()
               .antMatchers("/api/signin/**").permitAll()
               .antMatchers("/api/glee/**").hasAnyAuthority("ADMIN", "USER")
               .antMatchers("/api/users/**").hasAuthority("ADMIN")
               .antMatchers("/api/**").authenticated()
               .anyRequest().authenticated()
               .and().exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint).accessDeniedHandler(new CustomAccessDeniedHandler());
   }

}

下面的代码摘录是关于UserDetailsS​​ervice接口的实现, 以便提供资源所有者的身份验证。

package net.reliqs.gleeometer.security;

import net.reliqs.gleeometer.users.User;
import net.reliqs.gleeometer.users.UserRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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 UserService implements UserDetailsService {

   private final UserRepository repository;

   public UserService(UserRepository repository) {
       this.repository = repository;
   }

   @Override
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
       User user = repository.findByEmail(username).orElseThrow(() -> new RuntimeException("User not found: " + username));
       GrantedAuthority authority = new SimpleGrantedAuthority(user.getRole().name());
       return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), Arrays.asList(authority));
   }
}

下一节将介绍REST控制器的实现, 以了解如何映射安全性约束。

REST控制器

在REST控制器内部, 我们可以找到两种对每种资源方法应用访问控制的方法:

  • 使用Spring传入的OAuth2Authentication实例作为参数
  • 使用@PreAuthorize或@PostAuthorize批注
package net.reliqs.gleeometer.users;

import lombok.extern.slf4j.Slf4j;
import net.reliqs.gleeometer.errors.EntityNotFoundException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.ConstraintViolationException;
import javax.validation.Valid;
import javax.validation.constraints.Size;
import java.util.HashSet;

@RestController
@RequestMapping("/api/users")
@Slf4j
@Validated
class UserController {

   private final UserRepository repository;

   private final PasswordEncoder passwordEncoder;

   UserController(UserRepository repository, PasswordEncoder passwordEncoder) {
       this.repository = repository;
       this.passwordEncoder = passwordEncoder;
   }

   @GetMapping
   Page<User> all(@PageableDefault(size = Integer.MAX_VALUE) Pageable pageable, OAuth2Authentication authentication) {
       String auth = (String) authentication.getUserAuthentication().getPrincipal();
       String role = authentication.getAuthorities().iterator().next().getAuthority();
       if (role.equals(User.Role.USER.name())) {
           return repository.findAllByEmail(auth, pageable);
       }
       return repository.findAll(pageable);
   }

   @GetMapping("/search")
   Page<User> search(@RequestParam String email, Pageable pageable, OAuth2Authentication authentication) {
       String auth = (String) authentication.getUserAuthentication().getPrincipal();
       String role = authentication.getAuthorities().iterator().next().getAuthority();
       if (role.equals(User.Role.USER.name())) {
           return repository.findAllByEmailContainsAndEmail(email, auth, pageable);
       }
       return repository.findByEmailContains(email, pageable);
   }

   @GetMapping("/findByEmail")
   @PreAuthorize("!hasAuthority('USER') || (authentication.principal == #email)")
   User findByEmail(@RequestParam String email, OAuth2Authentication authentication) {
       return repository.findByEmail(email).orElseThrow(() -> new EntityNotFoundException(User.class, "email", email));
   }

   @GetMapping("/{id}")
   @PostAuthorize("!hasAuthority('USER') || (returnObject != null && returnObject.email == authentication.principal)")
   User one(@PathVariable Long id) {
       return repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString()));
   }

   @PutMapping("/{id}")
   @PreAuthorize("!hasAuthority('USER') || (authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)")
   void update(@PathVariable Long id, @Valid @RequestBody User res) {
       User u = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString()));
       res.setPassword(u.getPassword());
       res.setGlee(u.getGlee());
       repository.save(res);
   }

   @PostMapping
   @PreAuthorize("!hasAuthority('USER')")
   User create(@Valid @RequestBody User res) {
       return repository.save(res);
   }

   @DeleteMapping("/{id}")
   @PreAuthorize("!hasAuthority('USER')")
   void delete(@PathVariable Long id) {
       if (repository.existsById(id)) {
           repository.deleteById(id);
       } else {
           throw new EntityNotFoundException(User.class, "id", id.toString());
       }
   }

   @PutMapping("/{id}/changePassword")
   @PreAuthorize("!hasAuthority('USER') || (#oldPassword != null && !#oldPassword.isEmpty() && authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)")
   void changePassword(@PathVariable Long id, @RequestParam(required = false) String oldPassword, @Valid @Size(min = 3) @RequestParam String newPassword) {
       User user = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString()));
       if (oldPassword == null || oldPassword.isEmpty() || passwordEncoder.matches(oldPassword, user.getPassword())) {
           user.setPassword(passwordEncoder.encode(newPassword));
           repository.save(user);
       } else {
           throw new ConstraintViolationException("old password doesn't match", new HashSet<>());
       }
   }
}

总结

Spring Security和Spring Boot允许以几乎声明的方式快速设置完整的OAuth2授权/认证服务器。如本教程中所述, 可以通过直接从application.properties/yml文件配置OAuth2客户端的属性来进一步缩短设置。

所有源代码都可以在以下GitHub存储库中找到:spring-glee-o-meter。在此GitHub存储库中可以找到使用已发布资源的Angular客户端:glee-o-meter。

赞(0)
未经允许不得转载:srcmini » 使用Spring Boot进行OAuth2和JWT REST保护

评论 抢沙发

评论前必须登录!